Busca components (body, header, footer, botoes) e importa templates que existem na Meta mas nao estao cadastrados localmente. Retorna synced e imported separados.
293 lines
9.9 KiB
TypeScript
293 lines
9.9 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
|
|
import { WhatsappConfigService } from './whatsapp-config.service';
|
|
import { CreateWhatsappTemplateDto, UpdateWhatsappTemplateDto } from './dto/whatsapp.dto';
|
|
|
|
@Injectable()
|
|
export class WhatsappTemplateService {
|
|
private readonly logger = new Logger(WhatsappTemplateService.name);
|
|
|
|
constructor(
|
|
private readonly repo: WhatsappTemplateRepository,
|
|
private readonly configService: WhatsappConfigService,
|
|
) {}
|
|
|
|
async getTemplates() {
|
|
const result = await this.repo.listTemplates();
|
|
return result.rows;
|
|
}
|
|
|
|
async getTemplateById(id: number) {
|
|
const result = await this.repo.getTemplateById(id);
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
async saveTemplate(dto: CreateWhatsappTemplateDto) {
|
|
const metaName = this.toMetaName(dto.name);
|
|
const result = await this.repo.saveTemplate({
|
|
name: dto.name,
|
|
content: dto.content,
|
|
category: this.normalizeCategory(dto.category),
|
|
areaId: dto.areaId ?? null,
|
|
language: dto.language || 'pt_BR',
|
|
metaTemplateName: metaName,
|
|
headerType: dto.headerType || null,
|
|
headerText: dto.headerText || null,
|
|
footerText: dto.footerText || null,
|
|
buttonsJson: dto.buttons?.length ? JSON.stringify(dto.buttons) : null,
|
|
bodyVariablesJson: dto.bodyVariables?.length ? JSON.stringify(dto.bodyVariables) : null,
|
|
});
|
|
return result.rows[0];
|
|
}
|
|
|
|
async updateTemplate(id: number, dto: UpdateWhatsappTemplateDto) {
|
|
const metaName = this.toMetaName(dto.name);
|
|
const result = await this.repo.updateTemplate(id, {
|
|
name: dto.name,
|
|
content: dto.content,
|
|
category: this.normalizeCategory(dto.category),
|
|
areaId: dto.areaId ?? null,
|
|
language: dto.language || 'pt_BR',
|
|
metaTemplateName: metaName,
|
|
headerType: dto.headerType || null,
|
|
headerText: dto.headerText || null,
|
|
footerText: dto.footerText || null,
|
|
buttonsJson: dto.buttons?.length ? JSON.stringify(dto.buttons) : null,
|
|
bodyVariablesJson: dto.bodyVariables?.length ? JSON.stringify(dto.bodyVariables) : null,
|
|
});
|
|
return result.rows[0];
|
|
}
|
|
|
|
async submitToMeta(templateId: number) {
|
|
const template = await this.getTemplateById(templateId);
|
|
if (!template) throw new NotFoundException('Template não encontrado');
|
|
|
|
const config = await this.configService.getConfig();
|
|
if (!config.access_token || !config.waba_id) {
|
|
throw new BadRequestException(
|
|
'Access Token e WABA ID são obrigatórios para enviar templates. Configure em Admin → Canais → WhatsApp → Configurar.',
|
|
);
|
|
}
|
|
|
|
const metaName = template.meta_template_name || this.toMetaName(template.name);
|
|
const components = this.buildComponents(template);
|
|
|
|
const payload = {
|
|
name: metaName,
|
|
language: template.language || 'pt_BR',
|
|
category: template.category || 'UTILITY',
|
|
components,
|
|
};
|
|
|
|
this.logger.log(`[Meta Templates] Submetendo "${metaName}" para WABA ${config.waba_id}`);
|
|
|
|
const response = await fetch(
|
|
`https://graph.facebook.com/${config.api_version}/${config.waba_id}/message_templates`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${config.access_token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
},
|
|
);
|
|
|
|
const data = (await response.json()) as any;
|
|
|
|
if (!response.ok) {
|
|
this.logger.error('[Meta Templates] Erro ao submeter:', data?.error);
|
|
throw new BadRequestException(
|
|
data?.error?.message || `Erro ${response.status} ao submeter template à Meta`,
|
|
);
|
|
}
|
|
|
|
await this.repo.updateMetaInfo(templateId, {
|
|
metaTemplateId: String(data.id),
|
|
metaTemplateName: metaName,
|
|
metaStatus: data.status || 'PENDING',
|
|
});
|
|
|
|
return { success: true, metaId: data.id, metaName, status: data.status || 'PENDING' };
|
|
}
|
|
|
|
async syncFromMeta() {
|
|
const config = await this.configService.getConfig();
|
|
if (!config.access_token || !config.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,language,category,components&limit=250`,
|
|
{ headers: { Authorization: `Bearer ${config.access_token}` } },
|
|
);
|
|
|
|
const data = (await response.json()) as any;
|
|
|
|
if (!response.ok || !Array.isArray(data?.data)) {
|
|
this.logger.error('[Meta Templates] Erro ao buscar lista:', data?.error);
|
|
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 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, imported };
|
|
} catch (err: any) {
|
|
this.logger.error('[Meta Templates] Falha na sincronização:', err?.message);
|
|
return { synced: 0, imported: 0, error: 'Erro de conexão com a Meta' };
|
|
}
|
|
}
|
|
|
|
async handleTemplateStatusUpdate(data: {
|
|
event?: string;
|
|
message_template_name?: string;
|
|
message_template_id?: number | string;
|
|
reason?: string;
|
|
}) {
|
|
const status = data.event;
|
|
const name = data.message_template_name;
|
|
const id = String(data.message_template_id || '');
|
|
|
|
if (!status || !name) return;
|
|
|
|
this.logger.log(`[Meta Templates] Status automático: "${name}" → ${status}`);
|
|
await this.repo.updateMetaStatusByName(name, status, id);
|
|
}
|
|
|
|
async deleteTemplate(id: number) {
|
|
const result = await this.repo.deleteTemplate(id);
|
|
const metaName = result.rows[0]?.meta_template_name;
|
|
|
|
if (metaName) {
|
|
await this.deleteFromMeta(metaName).catch((err) =>
|
|
this.logger.warn(`[Meta Templates] Falha ao excluir "${metaName}" da Meta:`, err?.message),
|
|
);
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
private async deleteFromMeta(metaTemplateName: string) {
|
|
const config = await this.configService.getConfig();
|
|
if (!config.access_token || !config.waba_id) return;
|
|
|
|
await fetch(
|
|
`https://graph.facebook.com/${config.api_version}/${config.waba_id}/message_templates?name=${encodeURIComponent(metaTemplateName)}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${config.access_token}` },
|
|
},
|
|
);
|
|
}
|
|
|
|
private buildComponents(template: any): any[] {
|
|
const components: any[] = [];
|
|
|
|
if (template.header_type && template.header_text) {
|
|
components.push({
|
|
type: 'HEADER',
|
|
format: template.header_type,
|
|
text: template.header_text,
|
|
});
|
|
}
|
|
|
|
const bodyComp: any = { type: 'BODY', text: template.content };
|
|
const vars = Array.isArray(template.body_variables) ? template.body_variables : [];
|
|
if (vars.length > 0) {
|
|
bodyComp.example = { body_text: [vars.map((v: any) => v.example || v.label || `valor${v.index}`)] };
|
|
}
|
|
components.push(bodyComp);
|
|
|
|
if (template.footer_text) {
|
|
components.push({ type: 'FOOTER', text: template.footer_text });
|
|
}
|
|
|
|
const btns = Array.isArray(template.buttons) ? template.buttons : [];
|
|
if (btns.length > 0) {
|
|
components.push({
|
|
type: 'BUTTONS',
|
|
buttons: btns.map((b: any) => {
|
|
if (b.type === 'QUICK_REPLY') return { type: 'QUICK_REPLY', text: b.text };
|
|
if (b.type === 'URL') return { type: 'URL', text: b.text, url: b.url };
|
|
if (b.type === 'PHONE_NUMBER') return { type: 'PHONE_NUMBER', text: b.text, phone_number: b.phone };
|
|
return b;
|
|
}),
|
|
});
|
|
}
|
|
|
|
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')
|
|
.replace(/[̀-ͯ]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.substring(0, 512) || 'template';
|
|
}
|
|
|
|
private normalizeCategory(category?: string): string {
|
|
const normalized = String(category || 'UTILITY').trim().toUpperCase();
|
|
return ['UTILITY', 'MARKETING', 'AUTHENTICATION'].includes(normalized) ? normalized : 'UTILITY';
|
|
}
|
|
}
|