diff --git a/src/modules/whatsapp/dto/whatsapp.dto.ts b/src/modules/whatsapp/dto/whatsapp.dto.ts index b2a53ed..f88cefe 100644 --- a/src/modules/whatsapp/dto/whatsapp.dto.ts +++ b/src/modules/whatsapp/dto/whatsapp.dto.ts @@ -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 { diff --git a/src/modules/whatsapp/repositories/whatsapp-config.repository.ts b/src/modules/whatsapp/repositories/whatsapp-config.repository.ts index f3ebbc9..43bec45 100644 --- a/src/modules/whatsapp/repositories/whatsapp-config.repository.ts +++ b/src/modules/whatsapp/repositories/whatsapp-config.repository.ts @@ -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; diff --git a/src/modules/whatsapp/repositories/whatsapp-template.repository.ts b/src/modules/whatsapp/repositories/whatsapp-template.repository.ts index f323062..fdb9f03 100644 --- a/src/modules/whatsapp/repositories/whatsapp-template.repository.ts +++ b/src/modules/whatsapp/repositories/whatsapp-template.repository.ts @@ -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 { + 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]); } } diff --git a/src/modules/whatsapp/whatsapp-config.service.ts b/src/modules/whatsapp/whatsapp-config.service.ts index c3e2b9b..898efc4 100644 --- a/src/modules/whatsapp/whatsapp-config.service.ts +++ b/src/modules/whatsapp/whatsapp-config.service.ts @@ -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(); diff --git a/src/modules/whatsapp/whatsapp-template.service.ts b/src/modules/whatsapp/whatsapp-template.service.ts index 06efacf..b8b3e38 100644 --- a/src/modules/whatsapp/whatsapp-template.service.ts +++ b/src/modules/whatsapp/whatsapp-template.service.ts @@ -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'; } diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts index 2c36a6a..4866310 100644 --- a/src/modules/whatsapp/whatsapp.controller.ts +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -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,223 +49,103 @@ 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' }) @Get('webhook') @@ -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' }; + } } diff --git a/src/modules/whatsapp/whatsapp.service.ts b/src/modules/whatsapp/whatsapp.service.ts index 1d8fd35..18d3810 100644 --- a/src/modules/whatsapp/whatsapp.service.ts +++ b/src/modules/whatsapp/whatsapp.service.ts @@ -176,22 +176,94 @@ export class WhatsappService { variables?: Record, ) { 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, + ) { + 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) { 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 ?? []);