FEAT: Implementa gerenciamento de templates HSM Meta
Some checks failed
Deploy Dev / deploy (push) Has been cancelled

- Adiciona waba_id ao WhatsappConfigService/Repository/DTO
- Reescreve WhatsappTemplateRepository com colunas HSM (language,
  meta_template_name, header, footer, buttons, body_variables)
- Reescreve WhatsappTemplateService: submitToMeta(), syncFromMeta(),
  handleTemplateStatusUpdate() para webhook automático
- Adiciona endpoints POST /whatsapp/templates/:id/submit-meta e
  POST /whatsapp/templates/sync-meta no controller
- WhatsappService.startAttendance() envia como type=template quando
  meta_status=APPROVED; sendTemplateMessage() com body parameters
- Webhook message_template_status_update atualiza status no banco
  automaticamente sem precisar sincronizar manualmente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rafael Alves Lopes 2026-06-23 09:56:56 -03:00
parent f0044fb8e5
commit 9514d9df4d
7 changed files with 508 additions and 291 deletions

View File

@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsInt,
IsNotEmpty,
IsObject,
@ -127,12 +128,33 @@ export class CreateWhatsappTemplateDto {
@IsOptional()
@IsString()
@MaxLength(40)
requestedByRole?: string;
category?: string;
@IsOptional()
@IsString()
@MaxLength(40)
category?: string;
@MaxLength(10)
language?: string;
@IsOptional()
@IsString()
@MaxLength(20)
headerType?: string | null;
@IsOptional()
@IsString()
headerText?: string | null;
@IsOptional()
@IsString()
footerText?: string | null;
@IsOptional()
@IsArray()
buttons?: any[] | null;
@IsOptional()
@IsArray()
bodyVariables?: any[] | null;
}
export class UpdateWhatsappTemplateDto {
@ -154,6 +176,32 @@ export class UpdateWhatsappTemplateDto {
@IsString()
@MaxLength(40)
category?: string;
@IsOptional()
@IsString()
@MaxLength(10)
language?: string;
@IsOptional()
@IsString()
@MaxLength(20)
headerType?: string | null;
@IsOptional()
@IsString()
headerText?: string | null;
@IsOptional()
@IsString()
footerText?: string | null;
@IsOptional()
@IsArray()
buttons?: any[] | null;
@IsOptional()
@IsArray()
bodyVariables?: any[] | null;
}
export class SaveWhatsappConfigDto {
@ -176,6 +224,11 @@ export class SaveWhatsappConfigDto {
@IsString()
@MaxLength(255)
webhook_token?: string;
@IsOptional()
@IsString()
@MaxLength(100)
waba_id?: string;
}
export class ValidateWhatsappConfigDto {

View File

@ -17,6 +17,7 @@ export class WhatsappConfigRepository {
phone_number_id?: string | null;
api_version?: string | null;
webhook_token?: string | null;
waba_id?: string | null;
updated_by?: number | null;
}) {
const result = await this.database.query(
@ -25,6 +26,7 @@ export class WhatsappConfigRepository {
phone_number_id = CASE WHEN $2::TEXT IS NOT NULL THEN $2 ELSE phone_number_id END,
api_version = CASE WHEN $3::TEXT IS NOT NULL THEN $3 ELSE api_version END,
webhook_token = CASE WHEN $4::TEXT IS NOT NULL THEN $4 ELSE webhook_token END,
waba_id = CASE WHEN $6::TEXT IS NOT NULL THEN $6 ELSE waba_id END,
updated_at = NOW(),
updated_by = $5
RETURNING *`,
@ -34,6 +36,7 @@ export class WhatsappConfigRepository {
fields.api_version ?? null,
fields.webhook_token ?? null,
fields.updated_by ?? null,
fields.waba_id ?? null,
],
);
return result.rows[0] || null;

View File

@ -25,108 +25,145 @@ export class WhatsappTemplateRepository {
content: string;
category: string;
areaId?: number | null;
status: string;
requestedByRole: string;
adminApprovedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
metaSubmittedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
language: string;
metaTemplateName: string;
headerType?: string | null;
headerText?: string | null;
footerText?: string | null;
buttonsJson: string | null;
bodyVariablesJson: string | null;
}) {
return this.database.query(
`
INSERT INTO whatsapp_templates (
name,
content,
category,
area_id,
status,
requested_by_role,
admin_approved_at,
meta_submitted_at,
meta_approved_at,
updated_at
name, content, category, area_id, language,
meta_template_name, header_type, header_text, footer_text,
buttons, body_variables, status, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, ${input.adminApprovedAtSql}, ${input.metaSubmittedAtSql}, NULL, CURRENT_TIMESTAMP)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, 'draft', CURRENT_TIMESTAMP)
ON CONFLICT (name) DO UPDATE SET
content = EXCLUDED.content,
category = EXCLUDED.category,
area_id = EXCLUDED.area_id,
status = EXCLUDED.status,
requested_by_role = EXCLUDED.requested_by_role,
admin_approved_at = EXCLUDED.admin_approved_at,
meta_submitted_at = EXCLUDED.meta_submitted_at,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
content = EXCLUDED.content,
category = EXCLUDED.category,
area_id = EXCLUDED.area_id,
language = EXCLUDED.language,
meta_template_name = EXCLUDED.meta_template_name,
header_type = EXCLUDED.header_type,
header_text = EXCLUDED.header_text,
footer_text = EXCLUDED.footer_text,
buttons = EXCLUDED.buttons,
body_variables = EXCLUDED.body_variables,
status = 'draft',
meta_status = NULL,
meta_template_id = NULL,
meta_submitted_at = NULL,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`,
[input.name, input.content, input.category, input.areaId || null, input.status, input.requestedByRole],
[
input.name,
input.content,
input.category,
input.areaId || null,
input.language,
input.metaTemplateName,
input.headerType || null,
input.headerText || null,
input.footerText || null,
input.buttonsJson,
input.bodyVariablesJson,
],
);
}
updateTemplate(id: number, input: { name: string; content: string; category: string; areaId?: number | null }) {
updateTemplate(
id: number,
input: {
name: string;
content: string;
category: string;
areaId?: number | null;
language: string;
metaTemplateName: string;
headerType?: string | null;
headerText?: string | null;
footerText?: string | null;
buttonsJson: string | null;
bodyVariablesJson: string | null;
},
) {
return this.database.query(
`
UPDATE whatsapp_templates
SET
name = $1,
content = $2,
category = $3,
area_id = $4,
status = 'meta_review',
admin_approved_at = CURRENT_TIMESTAMP,
meta_submitted_at = CURRENT_TIMESTAMP,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $5
UPDATE whatsapp_templates SET
name = $1,
content = $2,
category = $3,
area_id = $4,
language = $5,
meta_template_name = $6,
header_type = $7,
header_text = $8,
footer_text = $9,
buttons = $10::jsonb,
body_variables = $11::jsonb,
status = 'draft',
meta_status = NULL,
meta_template_id = NULL,
meta_submitted_at = NULL,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $12
RETURNING *
`,
[input.name, input.content, input.category, input.areaId || null, id],
[
input.name,
input.content,
input.category,
input.areaId || null,
input.language,
input.metaTemplateName,
input.headerType || null,
input.headerText || null,
input.footerText || null,
input.buttonsJson,
input.bodyVariablesJson,
id,
],
);
}
approveTemplateByAdmin(id: number) {
updateMetaInfo(
id: number,
info: { metaTemplateId?: string; metaTemplateName?: string; metaStatus: string },
) {
return this.database.query(
`
UPDATE whatsapp_templates
SET
status = 'meta_review',
admin_approved_at = CURRENT_TIMESTAMP,
meta_submitted_at = CURRENT_TIMESTAMP,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *
`,
[id],
`UPDATE whatsapp_templates SET
meta_template_id = COALESCE($2, meta_template_id),
meta_template_name = COALESCE($3, meta_template_name),
meta_status = $4,
meta_submitted_at = CURRENT_TIMESTAMP,
status = 'pending',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *`,
[id, info.metaTemplateId || null, info.metaTemplateName || null, info.metaStatus],
);
}
rejectTemplateByAdmin(id: number) {
return this.database.query(
`
UPDATE whatsapp_templates
SET
status = 'rejected',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *
`,
[id],
async updateMetaStatusByName(metaName: string, metaStatus: string, metaId: string): Promise<number> {
const result = await this.database.query(
`UPDATE whatsapp_templates SET
meta_status = $2,
meta_template_id = COALESCE($3, meta_template_id),
meta_approved_at = CASE WHEN $2 = 'APPROVED' THEN CURRENT_TIMESTAMP ELSE meta_approved_at END,
updated_at = CURRENT_TIMESTAMP
WHERE meta_template_name = $1`,
[metaName, metaStatus, metaId || null],
);
return result.rowCount || 0;
}
deleteTemplate(id: number) {
return this.database.query('DELETE FROM whatsapp_templates WHERE id = $1', [id]);
}
refreshMetaApprovals() {
return this.database.query(`
UPDATE whatsapp_templates
SET
status = 'approved',
meta_approved_at = COALESCE(meta_approved_at, meta_submitted_at + INTERVAL '15 minutes'),
updated_at = CURRENT_TIMESTAMP
WHERE status = 'meta_review'
AND meta_submitted_at IS NOT NULL
AND meta_submitted_at <= CURRENT_TIMESTAMP - INTERVAL '15 minutes'
`);
return this.database.query('DELETE FROM whatsapp_templates WHERE id = $1 RETURNING meta_template_name', [id]);
}
}

View File

@ -6,6 +6,7 @@ interface WhatsappConfig {
phone_number_id: string | null;
api_version: string;
webhook_token: string | null;
waba_id: string | null;
}
@Injectable()
@ -25,6 +26,7 @@ export class WhatsappConfigService {
phone_number_id: row?.phone_number_id || process.env.META_PHONE_NUMBER_ID || null,
api_version: row?.api_version || process.env.META_API_VERSION || 'v25.0',
webhook_token: row?.webhook_token || process.env.META_WEBHOOK_TOKEN || null,
waba_id: row?.waba_id || process.env.META_WABA_ID || null,
};
this._cache = { config, expiresAt: Date.now() + this.CACHE_TTL_MS };
return config;
@ -38,6 +40,7 @@ export class WhatsappConfigService {
phone_number_id: effectivePhoneId || null,
api_version: row?.api_version || process.env.META_API_VERSION || 'v25.0',
webhook_token: row?.webhook_token || process.env.META_WEBHOOK_TOKEN || null,
waba_id: row?.waba_id || process.env.META_WABA_ID || null,
is_configured: !!(effectiveToken && effectivePhoneId),
has_token: !!effectiveToken,
token_source: row?.access_token ? 'database' : effectiveToken ? 'env' : null,
@ -46,7 +49,7 @@ export class WhatsappConfigService {
}
async saveConfig(
dto: { access_token?: string; phone_number_id?: string; api_version?: string; webhook_token?: string },
dto: { access_token?: string; phone_number_id?: string; api_version?: string; webhook_token?: string; waba_id?: string },
userId?: number,
) {
this._cache = null;
@ -55,6 +58,7 @@ export class WhatsappConfigService {
phone_number_id: dto.phone_number_id || null,
api_version: dto.api_version || null,
webhook_token: dto.webhook_token || null,
waba_id: dto.waba_id || null,
updated_by: userId,
});
return this.getPublicConfig();

View File

@ -1,65 +1,239 @@
import { Injectable } from '@nestjs/common';
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 {
constructor(private readonly whatsappTemplateRepository: WhatsappTemplateRepository) {}
private readonly logger = new Logger(WhatsappTemplateService.name);
constructor(
private readonly repo: WhatsappTemplateRepository,
private readonly configService: WhatsappConfigService,
) {}
async getTemplates() {
await this.whatsappTemplateRepository.refreshMetaApprovals();
const result = await this.whatsappTemplateRepository.listTemplates();
const result = await this.repo.listTemplates();
return result.rows;
}
async getTemplateById(id: number) {
await this.whatsappTemplateRepository.refreshMetaApprovals();
const result = await this.whatsappTemplateRepository.getTemplateById(id);
const result = await this.repo.getTemplateById(id);
return result.rows[0] || null;
}
async saveTemplate(name: string, content: string, areaId?: number | null, requestedByRole = 'admin', category = 'UTILITY') {
const isSupervisor = requestedByRole === 'supervisor';
const result = await this.whatsappTemplateRepository.saveTemplate({
name,
content,
category: this.normalizeTemplateCategory(category),
areaId,
status: isSupervisor ? 'admin_review' : 'meta_review',
requestedByRole,
adminApprovedAtSql: isSupervisor ? 'NULL' : 'CURRENT_TIMESTAMP',
metaSubmittedAtSql: isSupervisor ? 'NULL' : 'CURRENT_TIMESTAMP',
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 result.rows[0];
return { success: true, metaId: data.id, metaName, status: data.status || 'PENDING' };
}
async updateTemplate(id: number, name: string, content: string, areaId?: number | null, category = 'UTILITY') {
const result = await this.whatsappTemplateRepository.updateTemplate(id, {
name,
content,
areaId,
category: this.normalizeTemplateCategory(category),
});
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 result.rows[0];
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 approveTemplateByAdmin(id: number) {
const result = await this.whatsappTemplateRepository.approveTemplateByAdmin(id);
return result.rows[0];
}
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 || '');
async rejectTemplateByAdmin(id: number) {
const result = await this.whatsappTemplateRepository.rejectTemplateByAdmin(id);
return result.rows[0];
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) {
await this.whatsappTemplateRepository.deleteTemplate(id);
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 normalizeTemplateCategory(category?: string) {
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';
}

View File

@ -27,30 +27,21 @@ export class WhatsappController {
private readonly configService: WhatsappConfigService,
) {}
@ApiOperation({
summary: 'Consulta status da conexao WhatsApp',
description: 'Retorna o estado atual da sessao WhatsApp usada pelo backend, como conectado, aguardando QR Code ou desconectado.',
})
@ApiOperation({ summary: 'Consulta status da conexao WhatsApp' })
@ApiResponse({ status: 200, description: 'Status atual da conexao retornado.' })
@Get('status')
getStatus() {
return { status: this.whatsappService.getStatus() };
}
@ApiOperation({
summary: 'Lista conversas do WhatsApp',
description: 'Retorna os chats conhecidos pelo backend para alimentar o painel de atendimento em tempo real.',
})
@ApiOperation({ summary: 'Lista conversas do WhatsApp' })
@ApiResponse({ status: 200, description: 'Lista de conversas retornada.' })
@Get('chats')
async getChats() {
return this.whatsappService.getChats();
}
@ApiOperation({
summary: 'Lista mensagens de uma conversa',
description: 'Busca o historico de mensagens de um chat especifico para abrir a conversa no painel do atendente.',
})
@ApiOperation({ summary: 'Lista mensagens de uma conversa' })
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
@ApiResponse({ status: 200, description: 'Mensagens do chat retornadas.' })
@Get('messages/:chatId')
@ -58,222 +49,102 @@ export class WhatsappController {
return this.whatsappService.getChatMessages(chatId);
}
@ApiOperation({
summary: 'Baixa midia de uma mensagem',
description: 'Retorna a midia associada a uma mensagem do WhatsApp quando a mensagem possui anexo.',
})
@ApiOperation({ summary: 'Baixa midia de uma mensagem' })
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
@ApiParam({ name: 'messageId', description: 'Identificador da mensagem com midia.' })
@ApiResponse({ status: 200, description: 'Midia da mensagem retornada.' })
@Get('media/:chatId/:messageId')
async getMessageMedia(@Param('chatId') chatId: string, @Param('messageId') messageId: string) {
return this.whatsappService.getMessageMedia(chatId, messageId);
}
@ApiOperation({
summary: 'Envia mensagem no WhatsApp',
description: 'Envia uma mensagem ativa para um contato ou responde uma conversa existente, opcionalmente com midia em base64.',
})
@ApiBody({
schema: {
type: 'object',
required: ['to', 'message'],
properties: {
to: { type: 'string', example: '5511999999999@c.us' },
message: { type: 'string', example: 'Ola, como posso ajudar?' },
senderName: { type: 'string', example: 'Rafael Lopes' },
media: {
type: 'object',
nullable: true,
properties: {
data: { type: 'string', description: 'Arquivo em base64.' },
mimetype: { type: 'string', example: 'application/pdf' },
filename: { type: 'string', example: 'documento.pdf' },
},
},
},
},
})
@ApiOperation({ summary: 'Envia mensagem no WhatsApp' })
@ApiResponse({ status: 201, description: 'Mensagem enviada.' })
@Post('send')
async sendMessage(@Body() body: SendWhatsappMessageDto) {
return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName);
}
@ApiOperation({
summary: 'Inicia atendimento ativo',
description: 'Abre uma conversa ativa usando um template aprovado e registra o atendimento para acompanhamento no painel.',
})
@ApiBody({
schema: {
type: 'object',
required: ['to', 'templateId', 'userId'],
properties: {
to: { type: 'string', example: '5511999999999' },
templateId: { type: 'number', example: 1 },
userId: { type: 'number', example: 10 },
areaId: { type: 'number', nullable: true, example: 2 },
variables: { type: 'object', additionalProperties: { type: 'string', nullable: true } },
},
},
})
@ApiOperation({ summary: 'Inicia atendimento ativo' })
@ApiResponse({ status: 201, description: 'Atendimento ativo iniciado.' })
@Post('start-attendance')
async startAttendance(@Body() body: StartAttendanceDto) {
return this.whatsappService.startAttendance(body.to, body.templateId, body.userId, body.areaId, body.variables);
}
@ApiOperation({
summary: 'Assume uma conversa',
description: 'Vincula um chat a um atendente e, opcionalmente, a uma area de atendimento.',
})
@ApiBody({
schema: {
type: 'object',
required: ['chatId', 'userId'],
properties: {
chatId: { type: 'string', example: '5511999999999@c.us' },
userId: { type: 'string', example: '10' },
areaId: { type: 'string', example: '2' },
},
},
})
@ApiOperation({ summary: 'Assume uma conversa' })
@Post('assign')
async assignChat(@Body() body: AssignChatDto) {
return this.assignmentService.assignChat(body.chatId, body.userId, body.areaId);
}
@ApiOperation({
summary: 'Transfere uma conversa',
description: 'Encaminha o chat para outra area ou responsavel e registra observacao para contextualizar o proximo atendimento.',
})
@ApiBody({
schema: {
type: 'object',
required: ['chatId', 'areaId'],
properties: {
chatId: { type: 'string', example: '5511999999999@c.us' },
areaId: { type: 'number', example: 2 },
userId: { type: 'number', nullable: true, example: 10 },
note: { type: 'string', nullable: true, example: 'Tony e um colaborador e quer saber sobre ferias.' },
},
},
})
@ApiOperation({ summary: 'Transfere uma conversa' })
@Post('transfer')
async transferChat(@Body() body: TransferChatDto) {
return this.assignmentService.transferChat(body);
}
@ApiOperation({
summary: 'Libera conversa assumida',
description: 'Remove o vinculo de atendimento do chat, permitindo que outro atendente assuma a conversa.',
})
@ApiOperation({ summary: 'Libera conversa assumida' })
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
@Delete('release/:chatId')
async releaseChat(@Param('chatId') chatId: string) {
return this.assignmentService.releaseChat(chatId);
}
@ApiOperation({
summary: 'Fecha atendimento',
description: 'Finaliza o atendimento de um chat e remove o vinculo ativo com o atendente.',
})
@ApiBody({
schema: {
type: 'object',
required: ['chatId'],
properties: {
chatId: { type: 'string', example: '5511999999999@c.us' },
userId: { oneOf: [{ type: 'string' }, { type: 'number' }], nullable: true, example: 10 },
},
},
})
@ApiOperation({ summary: 'Fecha atendimento' })
@Post('close')
async closeChat(@Body() body: CloseChatDto) {
return this.assignmentService.closeChat(body.chatId, body.userId);
}
@ApiOperation({
summary: 'Consulta atribuicao do chat',
description: 'Retorna qual atendente ou area esta responsavel pela conversa no momento.',
})
@ApiOperation({ summary: 'Consulta atribuicao do chat' })
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
@Get('assignment/:chatId')
async getAssignment(@Param('chatId') chatId: string) {
return this.assignmentService.getAssignment(chatId);
}
@ApiOperation({
summary: 'Lista templates de atendimento',
description: 'Retorna os templates cadastrados para abertura ativa e mensagens padronizadas.',
})
// ---- Templates HSM ----
@ApiOperation({ summary: 'Lista templates HSM' })
@Get('templates')
async getTemplates() {
return this.whatsappTemplateService.getTemplates();
}
@ApiOperation({
summary: 'Cria template de mensagem',
description: 'Cadastra um template para abertura ativa ou resposta padronizada. Pode depender de aprovacao conforme o perfil solicitante.',
})
@ApiBody({
schema: {
type: 'object',
required: ['name', 'content'],
properties: {
name: { type: 'string', example: 'Boas vindas' },
content: { type: 'string', example: 'Ola {{nome}}, tudo bem?' },
areaId: { type: 'number', nullable: true, example: 2 },
requestedByRole: { type: 'string', example: 'admin' },
category: { type: 'string', example: 'atendimento' },
},
},
})
@ApiOperation({ summary: 'Cria template HSM' })
@Post('templates')
async saveTemplate(@Body() body: CreateWhatsappTemplateDto) {
return this.whatsappTemplateService.saveTemplate(body.name, body.content, body.areaId, body.requestedByRole, body.category);
return this.whatsappTemplateService.saveTemplate(body);
}
@ApiOperation({
summary: 'Atualiza template de mensagem',
description: 'Altera nome, conteudo, area ou categoria de um template existente.',
})
@ApiOperation({ summary: 'Sincroniza status de templates com a Meta' })
@Post('templates/sync-meta')
async syncTemplatesFromMeta() {
return this.whatsappTemplateService.syncFromMeta();
}
@ApiOperation({ summary: 'Atualiza template HSM existente' })
@ApiParam({ name: 'id', description: 'ID do template.' })
@Post('templates/update/:id')
async updateTemplate(@Param('id') id: string, @Body() body: UpdateWhatsappTemplateDto) {
return this.whatsappTemplateService.updateTemplate(Number(id), body.name, body.content, body.areaId, body.category);
return this.whatsappTemplateService.updateTemplate(Number(id), body);
}
@ApiOperation({
summary: 'Aprova template pelo admin',
description: 'Marca um template como aprovado para uso nos fluxos de atendimento.',
})
@ApiOperation({ summary: 'Envia template para aprovação da Meta' })
@ApiParam({ name: 'id', description: 'ID do template.' })
@Post('templates/approve-admin/:id')
async approveTemplateByAdmin(@Param('id') id: string) {
return this.whatsappTemplateService.approveTemplateByAdmin(Number(id));
@Post('templates/:id/submit-meta')
async submitTemplateToMeta(@Param('id') id: string) {
return this.whatsappTemplateService.submitToMeta(Number(id));
}
@ApiOperation({
summary: 'Rejeita template pelo admin',
description: 'Marca um template como rejeitado, impedindo seu uso nos fluxos de atendimento.',
})
@ApiParam({ name: 'id', description: 'ID do template.' })
@Post('templates/reject-admin/:id')
async rejectTemplateByAdmin(@Param('id') id: string) {
return this.whatsappTemplateService.rejectTemplateByAdmin(Number(id));
}
@ApiOperation({
summary: 'Remove template',
description: 'Exclui um template cadastrado.',
})
@ApiOperation({ summary: 'Remove template' })
@ApiParam({ name: 'id', description: 'ID do template.' })
@Delete('templates/:id')
async deleteTemplate(@Param('id') id: string) {
return this.whatsappTemplateService.deleteTemplate(Number(id));
}
// ---- Webhook Meta ----
@Public()
@ApiOperation({ summary: 'Verificação do webhook Meta' })
@ -291,13 +162,11 @@ export class WhatsappController {
throw new ForbiddenException('Token inválido');
}
@Public()
@ApiOperation({ summary: 'Recebe eventos do webhook Meta' })
@Post('webhook')
async receiveWebhook(@Body() body: any) {
await this.whatsappService.handleMetaWebhookEvent(body);
return { status: 'ok' };
}
@Public()
@ApiOperation({ summary: 'Recebe eventos do webhook Meta' })
@Post('webhook')
async receiveWebhook(@Body() body: any) {
await this.whatsappService.handleMetaWebhookEvent(body);
return { status: 'ok' };
}
}

View File

@ -176,22 +176,94 @@ export class WhatsappService {
variables?: Record<string, string | null | undefined>,
) {
const template = await this.whatsappTemplateService.getTemplateById(templateId);
if (!template) throw new Error('Template de WhatsApp nao encontrado');
if (!template) throw new Error('Template de WhatsApp não encontrado');
const renderedContent = this.renderTemplateContent(template.content, variables);
await this.sendMessage(to, renderedContent);
if (template.meta_template_name && template.meta_status === 'APPROVED') {
await this.sendTemplateMessage(to, template, variables);
} else {
const renderedContent = this.renderTemplateContent(template.content, variables);
await this.sendMessage(to, renderedContent);
}
const assignment = await this.assignmentService.assignChat(to, userId, areaId || null);
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to);
return {
chatId: to,
template: { ...template, content: renderedContent },
template,
messageId: null,
assignment: lockedAssignment || assignment,
};
}
private async sendTemplateMessage(
to: string,
template: any,
variables?: Record<string, string | null | undefined>,
) {
const { access_token: token, phone_number_id: phoneNumberId, api_version: apiVersion } =
await this.configService.getConfig();
if (!token || !phoneNumberId) throw new Error('WhatsApp não configurado.');
const bodyVars = Array.isArray(template.body_variables) ? template.body_variables : [];
const components: any[] = [];
if (bodyVars.length > 0) {
const params = bodyVars.map((v: any) => ({
type: 'text',
text: variables?.[v.label] ?? variables?.[String(v.index)] ?? v.example ?? '',
}));
components.push({ type: 'body', parameters: params });
}
const payload = {
messaging_product: 'whatsapp',
to,
type: 'template',
template: {
name: template.meta_template_name,
language: { code: template.language || 'pt_BR' },
...(components.length > 0 ? { components } : {}),
},
};
const response = await fetch(
`https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
this.logger.error(`[Meta] Falha ao enviar template para ${to}:`, error);
throw new Error(`Meta API erro ${response.status}: ${JSON.stringify(error)}`);
}
const result = (await response.json()) as any;
const wamid = result?.messages?.[0]?.id;
this.logger.log(`[Meta] Template "${template.meta_template_name}" enviado para ${to}: ${wamid}`);
if (wamid) {
await this.mensagensRepository.save({
wamid,
chatId: to,
fromMe: true,
type: 'template',
body: `[Template: ${template.name}]`,
senderName: 'Sistema',
status: 'sent',
timestamp: Math.floor(Date.now() / 1000),
});
}
await this.assignmentService.clearTransferNote(to);
return result;
}
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => {
const cleanKey = String(key || '').trim();
@ -209,10 +281,15 @@ export class WhatsappService {
for (const entry of body?.entry ?? []) {
for (const change of entry?.changes ?? []) {
if (change.field !== 'messages') continue;
const value = change.value;
if (change.field === 'message_template_status_update') {
await this.whatsappTemplateService.handleTemplateStatusUpdate(value);
continue;
}
if (change.field !== 'messages') continue;
if (value?.messages?.length) {
for (const message of value.messages) {
await this.handleMetaIncomingMessage(message, value.contacts ?? []);