FEAT: Implementa gerenciamento de templates HSM Meta
Some checks failed
Deploy Dev / deploy (push) Has been cancelled
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:
parent
f0044fb8e5
commit
9514d9df4d
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ?? []);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user