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'; } }