omnichannel-backend/src/modules/whatsapp/whatsapp-template.service.ts

293 lines
9.9 KiB
TypeScript
Raw Normal View History

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