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

241 lines
8.1 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, 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`,
{ 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, error: data?.error?.message || 'Erro ao sincronizar' };
}
let synced = 0;
for (const t of data.data) {
const updated = await this.repo.updateMetaStatusByName(t.name, t.status, String(t.id));
if (updated > 0) synced++;
}
return { synced };
} 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' };
}
}
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 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';
}
}