Merge pull request 'FEAT: syncFromMeta importa templates pre-aprovados da Meta' (#6) from fix/templates into dev
All checks were successful
Deploy Dev — Backend / Deploy para VM Dev (push) Successful in 7s

Reviewed-on: #6
This commit is contained in:
Rafael Alves Lopes 2026-07-23 15:12:14 -03:00
commit abfcbf2aee
2 changed files with 105 additions and 7 deletions

View File

@ -163,6 +163,52 @@ export class WhatsappTemplateRepository {
return result.rowCount || 0;
}
async importFromMeta(input: {
metaTemplateId: string;
metaTemplateName: string;
metaStatus: string;
language: string;
category: string;
content: string;
headerType?: string | null;
headerText?: string | null;
footerText?: string | null;
buttonsJson: string | null;
}): Promise<boolean> {
try {
const result = await this.database.query(
`INSERT INTO whatsapp_templates (
name, content, category, language,
meta_template_name, meta_template_id, meta_status,
header_type, header_text, footer_text, buttons, body_variables,
status, meta_approved_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, NULL,
CASE WHEN $7 = 'APPROVED' THEN 'approved' ELSE 'pending' END,
CASE WHEN $7 = 'APPROVED' THEN CURRENT_TIMESTAMP ELSE NULL END,
CURRENT_TIMESTAMP
)
ON CONFLICT (meta_template_name) WHERE meta_template_name IS NOT NULL DO NOTHING`,
[
input.metaTemplateName,
input.content,
input.category,
input.language,
input.metaTemplateName,
input.metaTemplateId,
input.metaStatus,
input.headerType || null,
input.headerText || null,
input.footerText || null,
input.buttonsJson,
],
);
return (result.rowCount || 0) > 0;
} catch {
return false;
}
}
deleteTemplate(id: number) {
return this.database.query('DELETE FROM whatsapp_templates WHERE id = $1 RETURNING meta_template_name', [id]);
}

View File

@ -114,12 +114,12 @@ export class WhatsappTemplateService {
async syncFromMeta() {
const config = await this.configService.getConfig();
if (!config.access_token || !config.waba_id) {
return { synced: 0, error: 'WhatsApp não configurado com WABA ID' };
return { synced: 0, imported: 0, error: 'WhatsApp não configurado com WABA ID' };
}
try {
const response = await fetch(
`https://graph.facebook.com/${config.api_version}/${config.waba_id}/message_templates?fields=id,name,status&limit=250`,
`https://graph.facebook.com/${config.api_version}/${config.waba_id}/message_templates?fields=id,name,status,language,category,components&limit=250`,
{ headers: { Authorization: `Bearer ${config.access_token}` } },
);
@ -127,19 +127,44 @@ export class WhatsappTemplateService {
if (!response.ok || !Array.isArray(data?.data)) {
this.logger.error('[Meta Templates] Erro ao buscar lista:', data?.error);
return { synced: 0, error: data?.error?.message || 'Erro ao sincronizar' };
return { synced: 0, imported: 0, error: data?.error?.message || 'Erro ao sincronizar' };
}
let synced = 0;
let imported = 0;
for (const t of data.data) {
const updated = await this.repo.updateMetaStatusByName(t.name, t.status, String(t.id));
if (updated > 0) synced++;
const metaId = String(t.id);
const updated = await this.repo.updateMetaStatusByName(t.name, t.status, metaId);
if (updated > 0) {
synced++;
continue;
}
const parsed = this.parseComponents(t.components || []);
const wasInserted = await this.repo.importFromMeta({
metaTemplateId: metaId,
metaTemplateName: t.name,
metaStatus: t.status,
language: t.language || 'pt_BR',
category: this.normalizeCategory(t.category),
content: parsed.content,
headerType: parsed.headerType,
headerText: parsed.headerText,
footerText: parsed.footerText,
buttonsJson: parsed.buttons?.length ? JSON.stringify(parsed.buttons) : null,
});
if (wasInserted) {
imported++;
this.logger.log(`[Meta Templates] Importado: "${t.name}" (${t.status})`);
}
}
return { synced };
return { synced, imported };
} catch (err: any) {
this.logger.error('[Meta Templates] Falha na sincronização:', err?.message);
return { synced: 0, error: 'Erro de conexão com a Meta' };
return { synced: 0, imported: 0, error: 'Erro de conexão com a Meta' };
}
}
@ -223,6 +248,33 @@ export class WhatsappTemplateService {
return components;
}
private parseComponents(components: any[]): {
content: string;
headerType?: string | null;
headerText?: string | null;
footerText?: string | null;
buttons?: any[];
} {
const result: any = { content: '', headerType: null, headerText: null, footerText: null, buttons: [] };
for (const c of components || []) {
if (c.type === 'BODY') result.content = c.text || '';
else if (c.type === 'HEADER') {
result.headerType = c.format || null;
result.headerText = c.format === 'TEXT' ? (c.text || null) : null;
}
else if (c.type === 'FOOTER') result.footerText = c.text || null;
else if (c.type === 'BUTTONS') {
result.buttons = (c.buttons || []).map((b: any) => ({
type: b.type,
text: b.text || '',
url: b.url || '',
phone: b.phone_number || '',
}));
}
}
return result;
}
private toMetaName(displayName: string): string {
return String(displayName || '')
.normalize('NFD')