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 { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsObject,
|
IsObject,
|
||||||
@ -127,12 +128,33 @@ export class CreateWhatsappTemplateDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(40)
|
@MaxLength(40)
|
||||||
requestedByRole?: string;
|
category?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(40)
|
@MaxLength(10)
|
||||||
category?: string;
|
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 {
|
export class UpdateWhatsappTemplateDto {
|
||||||
@ -154,6 +176,32 @@ export class UpdateWhatsappTemplateDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(40)
|
@MaxLength(40)
|
||||||
category?: string;
|
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 {
|
export class SaveWhatsappConfigDto {
|
||||||
@ -176,6 +224,11 @@ export class SaveWhatsappConfigDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
webhook_token?: string;
|
webhook_token?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
waba_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ValidateWhatsappConfigDto {
|
export class ValidateWhatsappConfigDto {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export class WhatsappConfigRepository {
|
|||||||
phone_number_id?: string | null;
|
phone_number_id?: string | null;
|
||||||
api_version?: string | null;
|
api_version?: string | null;
|
||||||
webhook_token?: string | null;
|
webhook_token?: string | null;
|
||||||
|
waba_id?: string | null;
|
||||||
updated_by?: number | null;
|
updated_by?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const result = await this.database.query(
|
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,
|
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,
|
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,
|
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_at = NOW(),
|
||||||
updated_by = $5
|
updated_by = $5
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
@ -34,6 +36,7 @@ export class WhatsappConfigRepository {
|
|||||||
fields.api_version ?? null,
|
fields.api_version ?? null,
|
||||||
fields.webhook_token ?? null,
|
fields.webhook_token ?? null,
|
||||||
fields.updated_by ?? null,
|
fields.updated_by ?? null,
|
||||||
|
fields.waba_id ?? null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
|
|||||||
@ -25,108 +25,145 @@ export class WhatsappTemplateRepository {
|
|||||||
content: string;
|
content: string;
|
||||||
category: string;
|
category: string;
|
||||||
areaId?: number | null;
|
areaId?: number | null;
|
||||||
status: string;
|
language: string;
|
||||||
requestedByRole: string;
|
metaTemplateName: string;
|
||||||
adminApprovedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
|
headerType?: string | null;
|
||||||
metaSubmittedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
|
headerText?: string | null;
|
||||||
|
footerText?: string | null;
|
||||||
|
buttonsJson: string | null;
|
||||||
|
bodyVariablesJson: string | null;
|
||||||
}) {
|
}) {
|
||||||
return this.database.query(
|
return this.database.query(
|
||||||
`
|
`
|
||||||
INSERT INTO whatsapp_templates (
|
INSERT INTO whatsapp_templates (
|
||||||
name,
|
name, content, category, area_id, language,
|
||||||
content,
|
meta_template_name, header_type, header_text, footer_text,
|
||||||
category,
|
buttons, body_variables, status, updated_at
|
||||||
area_id,
|
|
||||||
status,
|
|
||||||
requested_by_role,
|
|
||||||
admin_approved_at,
|
|
||||||
meta_submitted_at,
|
|
||||||
meta_approved_at,
|
|
||||||
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
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
content = EXCLUDED.content,
|
content = EXCLUDED.content,
|
||||||
category = EXCLUDED.category,
|
category = EXCLUDED.category,
|
||||||
area_id = EXCLUDED.area_id,
|
area_id = EXCLUDED.area_id,
|
||||||
status = EXCLUDED.status,
|
language = EXCLUDED.language,
|
||||||
requested_by_role = EXCLUDED.requested_by_role,
|
meta_template_name = EXCLUDED.meta_template_name,
|
||||||
admin_approved_at = EXCLUDED.admin_approved_at,
|
header_type = EXCLUDED.header_type,
|
||||||
meta_submitted_at = EXCLUDED.meta_submitted_at,
|
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,
|
meta_approved_at = NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING *
|
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(
|
return this.database.query(
|
||||||
`
|
`
|
||||||
UPDATE whatsapp_templates
|
UPDATE whatsapp_templates SET
|
||||||
SET
|
|
||||||
name = $1,
|
name = $1,
|
||||||
content = $2,
|
content = $2,
|
||||||
category = $3,
|
category = $3,
|
||||||
area_id = $4,
|
area_id = $4,
|
||||||
status = 'meta_review',
|
language = $5,
|
||||||
admin_approved_at = CURRENT_TIMESTAMP,
|
meta_template_name = $6,
|
||||||
meta_submitted_at = CURRENT_TIMESTAMP,
|
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,
|
meta_approved_at = NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $5
|
WHERE id = $12
|
||||||
RETURNING *
|
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(
|
return this.database.query(
|
||||||
`
|
`UPDATE whatsapp_templates SET
|
||||||
UPDATE whatsapp_templates
|
meta_template_id = COALESCE($2, meta_template_id),
|
||||||
SET
|
meta_template_name = COALESCE($3, meta_template_name),
|
||||||
status = 'meta_review',
|
meta_status = $4,
|
||||||
admin_approved_at = CURRENT_TIMESTAMP,
|
|
||||||
meta_submitted_at = CURRENT_TIMESTAMP,
|
meta_submitted_at = CURRENT_TIMESTAMP,
|
||||||
meta_approved_at = NULL,
|
status = 'pending',
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *
|
RETURNING *`,
|
||||||
`,
|
[id, info.metaTemplateId || null, info.metaTemplateName || null, info.metaStatus],
|
||||||
[id],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
rejectTemplateByAdmin(id: number) {
|
async updateMetaStatusByName(metaName: string, metaStatus: string, metaId: string): Promise<number> {
|
||||||
return this.database.query(
|
const result = await this.database.query(
|
||||||
`
|
`UPDATE whatsapp_templates SET
|
||||||
UPDATE whatsapp_templates
|
meta_status = $2,
|
||||||
SET
|
meta_template_id = COALESCE($3, meta_template_id),
|
||||||
status = 'rejected',
|
meta_approved_at = CASE WHEN $2 = 'APPROVED' THEN CURRENT_TIMESTAMP ELSE meta_approved_at END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
WHERE meta_template_name = $1`,
|
||||||
RETURNING *
|
[metaName, metaStatus, metaId || null],
|
||||||
`,
|
|
||||||
[id],
|
|
||||||
);
|
);
|
||||||
|
return result.rowCount || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteTemplate(id: number) {
|
deleteTemplate(id: number) {
|
||||||
return this.database.query('DELETE FROM whatsapp_templates WHERE id = $1', [id]);
|
return this.database.query('DELETE FROM whatsapp_templates WHERE id = $1 RETURNING meta_template_name', [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'
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ interface WhatsappConfig {
|
|||||||
phone_number_id: string | null;
|
phone_number_id: string | null;
|
||||||
api_version: string;
|
api_version: string;
|
||||||
webhook_token: string | null;
|
webhook_token: string | null;
|
||||||
|
waba_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -25,6 +26,7 @@ export class WhatsappConfigService {
|
|||||||
phone_number_id: row?.phone_number_id || process.env.META_PHONE_NUMBER_ID || null,
|
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',
|
api_version: row?.api_version || process.env.META_API_VERSION || 'v25.0',
|
||||||
webhook_token: row?.webhook_token || process.env.META_WEBHOOK_TOKEN || null,
|
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 };
|
this._cache = { config, expiresAt: Date.now() + this.CACHE_TTL_MS };
|
||||||
return config;
|
return config;
|
||||||
@ -38,6 +40,7 @@ export class WhatsappConfigService {
|
|||||||
phone_number_id: effectivePhoneId || null,
|
phone_number_id: effectivePhoneId || null,
|
||||||
api_version: row?.api_version || process.env.META_API_VERSION || 'v25.0',
|
api_version: row?.api_version || process.env.META_API_VERSION || 'v25.0',
|
||||||
webhook_token: row?.webhook_token || process.env.META_WEBHOOK_TOKEN || null,
|
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),
|
is_configured: !!(effectiveToken && effectivePhoneId),
|
||||||
has_token: !!effectiveToken,
|
has_token: !!effectiveToken,
|
||||||
token_source: row?.access_token ? 'database' : effectiveToken ? 'env' : null,
|
token_source: row?.access_token ? 'database' : effectiveToken ? 'env' : null,
|
||||||
@ -46,7 +49,7 @@ export class WhatsappConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveConfig(
|
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,
|
userId?: number,
|
||||||
) {
|
) {
|
||||||
this._cache = null;
|
this._cache = null;
|
||||||
@ -55,6 +58,7 @@ export class WhatsappConfigService {
|
|||||||
phone_number_id: dto.phone_number_id || null,
|
phone_number_id: dto.phone_number_id || null,
|
||||||
api_version: dto.api_version || null,
|
api_version: dto.api_version || null,
|
||||||
webhook_token: dto.webhook_token || null,
|
webhook_token: dto.webhook_token || null,
|
||||||
|
waba_id: dto.waba_id || null,
|
||||||
updated_by: userId,
|
updated_by: userId,
|
||||||
});
|
});
|
||||||
return this.getPublicConfig();
|
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 { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
|
||||||
|
import { WhatsappConfigService } from './whatsapp-config.service';
|
||||||
|
import { CreateWhatsappTemplateDto, UpdateWhatsappTemplateDto } from './dto/whatsapp.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WhatsappTemplateService {
|
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() {
|
async getTemplates() {
|
||||||
await this.whatsappTemplateRepository.refreshMetaApprovals();
|
const result = await this.repo.listTemplates();
|
||||||
const result = await this.whatsappTemplateRepository.listTemplates();
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTemplateById(id: number) {
|
async getTemplateById(id: number) {
|
||||||
await this.whatsappTemplateRepository.refreshMetaApprovals();
|
const result = await this.repo.getTemplateById(id);
|
||||||
const result = await this.whatsappTemplateRepository.getTemplateById(id);
|
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveTemplate(name: string, content: string, areaId?: number | null, requestedByRole = 'admin', category = 'UTILITY') {
|
async saveTemplate(dto: CreateWhatsappTemplateDto) {
|
||||||
const isSupervisor = requestedByRole === 'supervisor';
|
const metaName = this.toMetaName(dto.name);
|
||||||
const result = await this.whatsappTemplateRepository.saveTemplate({
|
const result = await this.repo.saveTemplate({
|
||||||
name,
|
name: dto.name,
|
||||||
content,
|
content: dto.content,
|
||||||
category: this.normalizeTemplateCategory(category),
|
category: this.normalizeCategory(dto.category),
|
||||||
areaId,
|
areaId: dto.areaId ?? null,
|
||||||
status: isSupervisor ? 'admin_review' : 'meta_review',
|
language: dto.language || 'pt_BR',
|
||||||
requestedByRole,
|
metaTemplateName: metaName,
|
||||||
adminApprovedAtSql: isSupervisor ? 'NULL' : 'CURRENT_TIMESTAMP',
|
headerType: dto.headerType || null,
|
||||||
metaSubmittedAtSql: isSupervisor ? 'NULL' : 'CURRENT_TIMESTAMP',
|
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') {
|
async syncFromMeta() {
|
||||||
const result = await this.whatsappTemplateRepository.updateTemplate(id, {
|
const config = await this.configService.getConfig();
|
||||||
name,
|
if (!config.access_token || !config.waba_id) {
|
||||||
content,
|
return { synced: 0, error: 'WhatsApp não configurado com WABA ID' };
|
||||||
areaId,
|
|
||||||
category: this.normalizeTemplateCategory(category),
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.rows[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveTemplateByAdmin(id: number) {
|
try {
|
||||||
const result = await this.whatsappTemplateRepository.approveTemplateByAdmin(id);
|
const response = await fetch(
|
||||||
return result.rows[0];
|
`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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async rejectTemplateByAdmin(id: number) {
|
let synced = 0;
|
||||||
const result = await this.whatsappTemplateRepository.rejectTemplateByAdmin(id);
|
for (const t of data.data) {
|
||||||
return result.rows[0];
|
const updated = await this.repo.updateMetaStatusByName(t.name, t.status, String(t.id));
|
||||||
|
if (updated > 0) synced++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { synced };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error('[Meta Templates] Falha na sincronização:', err?.message);
|
||||||
|
return { synced: 0, error: 'Erro de conexão com a Meta' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleTemplateStatusUpdate(data: {
|
||||||
|
event?: string;
|
||||||
|
message_template_name?: string;
|
||||||
|
message_template_id?: number | string;
|
||||||
|
reason?: string;
|
||||||
|
}) {
|
||||||
|
const status = data.event;
|
||||||
|
const name = data.message_template_name;
|
||||||
|
const id = String(data.message_template_id || '');
|
||||||
|
|
||||||
|
if (!status || !name) return;
|
||||||
|
|
||||||
|
this.logger.log(`[Meta Templates] Status automático: "${name}" → ${status}`);
|
||||||
|
await this.repo.updateMetaStatusByName(name, status, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTemplate(id: number) {
|
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 };
|
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();
|
const normalized = String(category || 'UTILITY').trim().toUpperCase();
|
||||||
return ['UTILITY', 'MARKETING', 'AUTHENTICATION'].includes(normalized) ? normalized : 'UTILITY';
|
return ['UTILITY', 'MARKETING', 'AUTHENTICATION'].includes(normalized) ? normalized : 'UTILITY';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,30 +27,21 @@ export class WhatsappController {
|
|||||||
private readonly configService: WhatsappConfigService,
|
private readonly configService: WhatsappConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Consulta status da conexao WhatsApp' })
|
||||||
summary: 'Consulta status da conexao WhatsApp',
|
|
||||||
description: 'Retorna o estado atual da sessao WhatsApp usada pelo backend, como conectado, aguardando QR Code ou desconectado.',
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'Status atual da conexao retornado.' })
|
@ApiResponse({ status: 200, description: 'Status atual da conexao retornado.' })
|
||||||
@Get('status')
|
@Get('status')
|
||||||
getStatus() {
|
getStatus() {
|
||||||
return { status: this.whatsappService.getStatus() };
|
return { status: this.whatsappService.getStatus() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Lista conversas do WhatsApp' })
|
||||||
summary: 'Lista conversas do WhatsApp',
|
|
||||||
description: 'Retorna os chats conhecidos pelo backend para alimentar o painel de atendimento em tempo real.',
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'Lista de conversas retornada.' })
|
@ApiResponse({ status: 200, description: 'Lista de conversas retornada.' })
|
||||||
@Get('chats')
|
@Get('chats')
|
||||||
async getChats() {
|
async getChats() {
|
||||||
return this.whatsappService.getChats();
|
return this.whatsappService.getChats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Lista mensagens de uma conversa' })
|
||||||
summary: 'Lista mensagens de uma conversa',
|
|
||||||
description: 'Busca o historico de mensagens de um chat especifico para abrir a conversa no painel do atendente.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
||||||
@ApiResponse({ status: 200, description: 'Mensagens do chat retornadas.' })
|
@ApiResponse({ status: 200, description: 'Mensagens do chat retornadas.' })
|
||||||
@Get('messages/:chatId')
|
@Get('messages/:chatId')
|
||||||
@ -58,222 +49,102 @@ export class WhatsappController {
|
|||||||
return this.whatsappService.getChatMessages(chatId);
|
return this.whatsappService.getChatMessages(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Baixa midia de uma mensagem' })
|
||||||
summary: 'Baixa midia de uma mensagem',
|
|
||||||
description: 'Retorna a midia associada a uma mensagem do WhatsApp quando a mensagem possui anexo.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
||||||
@ApiParam({ name: 'messageId', description: 'Identificador da mensagem com midia.' })
|
@ApiParam({ name: 'messageId', description: 'Identificador da mensagem com midia.' })
|
||||||
@ApiResponse({ status: 200, description: 'Midia da mensagem retornada.' })
|
|
||||||
@Get('media/:chatId/:messageId')
|
@Get('media/:chatId/:messageId')
|
||||||
async getMessageMedia(@Param('chatId') chatId: string, @Param('messageId') messageId: string) {
|
async getMessageMedia(@Param('chatId') chatId: string, @Param('messageId') messageId: string) {
|
||||||
return this.whatsappService.getMessageMedia(chatId, messageId);
|
return this.whatsappService.getMessageMedia(chatId, messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Envia mensagem no WhatsApp' })
|
||||||
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' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 201, description: 'Mensagem enviada.' })
|
@ApiResponse({ status: 201, description: 'Mensagem enviada.' })
|
||||||
@Post('send')
|
@Post('send')
|
||||||
async sendMessage(@Body() body: SendWhatsappMessageDto) {
|
async sendMessage(@Body() body: SendWhatsappMessageDto) {
|
||||||
return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName);
|
return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Inicia atendimento ativo' })
|
||||||
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 } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 201, description: 'Atendimento ativo iniciado.' })
|
@ApiResponse({ status: 201, description: 'Atendimento ativo iniciado.' })
|
||||||
@Post('start-attendance')
|
@Post('start-attendance')
|
||||||
async startAttendance(@Body() body: StartAttendanceDto) {
|
async startAttendance(@Body() body: StartAttendanceDto) {
|
||||||
return this.whatsappService.startAttendance(body.to, body.templateId, body.userId, body.areaId, body.variables);
|
return this.whatsappService.startAttendance(body.to, body.templateId, body.userId, body.areaId, body.variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Assume uma conversa' })
|
||||||
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' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@Post('assign')
|
@Post('assign')
|
||||||
async assignChat(@Body() body: AssignChatDto) {
|
async assignChat(@Body() body: AssignChatDto) {
|
||||||
return this.assignmentService.assignChat(body.chatId, body.userId, body.areaId);
|
return this.assignmentService.assignChat(body.chatId, body.userId, body.areaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Transfere uma conversa' })
|
||||||
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.' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@Post('transfer')
|
@Post('transfer')
|
||||||
async transferChat(@Body() body: TransferChatDto) {
|
async transferChat(@Body() body: TransferChatDto) {
|
||||||
return this.assignmentService.transferChat(body);
|
return this.assignmentService.transferChat(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Libera conversa assumida' })
|
||||||
summary: 'Libera conversa assumida',
|
|
||||||
description: 'Remove o vinculo de atendimento do chat, permitindo que outro atendente assuma a conversa.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
||||||
@Delete('release/:chatId')
|
@Delete('release/:chatId')
|
||||||
async releaseChat(@Param('chatId') chatId: string) {
|
async releaseChat(@Param('chatId') chatId: string) {
|
||||||
return this.assignmentService.releaseChat(chatId);
|
return this.assignmentService.releaseChat(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Fecha atendimento' })
|
||||||
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 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@Post('close')
|
@Post('close')
|
||||||
async closeChat(@Body() body: CloseChatDto) {
|
async closeChat(@Body() body: CloseChatDto) {
|
||||||
return this.assignmentService.closeChat(body.chatId, body.userId);
|
return this.assignmentService.closeChat(body.chatId, body.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Consulta atribuicao do chat' })
|
||||||
summary: 'Consulta atribuicao do chat',
|
|
||||||
description: 'Retorna qual atendente ou area esta responsavel pela conversa no momento.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
||||||
@Get('assignment/:chatId')
|
@Get('assignment/:chatId')
|
||||||
async getAssignment(@Param('chatId') chatId: string) {
|
async getAssignment(@Param('chatId') chatId: string) {
|
||||||
return this.assignmentService.getAssignment(chatId);
|
return this.assignmentService.getAssignment(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
// ---- Templates HSM ----
|
||||||
summary: 'Lista templates de atendimento',
|
|
||||||
description: 'Retorna os templates cadastrados para abertura ativa e mensagens padronizadas.',
|
@ApiOperation({ summary: 'Lista templates HSM' })
|
||||||
})
|
|
||||||
@Get('templates')
|
@Get('templates')
|
||||||
async getTemplates() {
|
async getTemplates() {
|
||||||
return this.whatsappTemplateService.getTemplates();
|
return this.whatsappTemplateService.getTemplates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Cria template HSM' })
|
||||||
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' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@Post('templates')
|
@Post('templates')
|
||||||
async saveTemplate(@Body() body: CreateWhatsappTemplateDto) {
|
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({
|
@ApiOperation({ summary: 'Sincroniza status de templates com a Meta' })
|
||||||
summary: 'Atualiza template de mensagem',
|
@Post('templates/sync-meta')
|
||||||
description: 'Altera nome, conteudo, area ou categoria de um template existente.',
|
async syncTemplatesFromMeta() {
|
||||||
})
|
return this.whatsappTemplateService.syncFromMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Atualiza template HSM existente' })
|
||||||
@ApiParam({ name: 'id', description: 'ID do template.' })
|
@ApiParam({ name: 'id', description: 'ID do template.' })
|
||||||
@Post('templates/update/:id')
|
@Post('templates/update/:id')
|
||||||
async updateTemplate(@Param('id') id: string, @Body() body: UpdateWhatsappTemplateDto) {
|
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({
|
@ApiOperation({ summary: 'Envia template para aprovação da Meta' })
|
||||||
summary: 'Aprova template pelo admin',
|
|
||||||
description: 'Marca um template como aprovado para uso nos fluxos de atendimento.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'ID do template.' })
|
@ApiParam({ name: 'id', description: 'ID do template.' })
|
||||||
@Post('templates/approve-admin/:id')
|
@Post('templates/:id/submit-meta')
|
||||||
async approveTemplateByAdmin(@Param('id') id: string) {
|
async submitTemplateToMeta(@Param('id') id: string) {
|
||||||
return this.whatsappTemplateService.approveTemplateByAdmin(Number(id));
|
return this.whatsappTemplateService.submitToMeta(Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Remove template' })
|
||||||
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.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'ID do template.' })
|
@ApiParam({ name: 'id', description: 'ID do template.' })
|
||||||
@Delete('templates/:id')
|
@Delete('templates/:id')
|
||||||
async deleteTemplate(@Param('id') id: string) {
|
async deleteTemplate(@Param('id') id: string) {
|
||||||
return this.whatsappTemplateService.deleteTemplate(Number(id));
|
return this.whatsappTemplateService.deleteTemplate(Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Webhook Meta ----
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Verificação do webhook Meta' })
|
@ApiOperation({ summary: 'Verificação do webhook Meta' })
|
||||||
@ -298,6 +169,4 @@ async receiveWebhook(@Body() body: any) {
|
|||||||
await this.whatsappService.handleMetaWebhookEvent(body);
|
await this.whatsappService.handleMetaWebhookEvent(body);
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -176,22 +176,94 @@ export class WhatsappService {
|
|||||||
variables?: Record<string, string | null | undefined>,
|
variables?: Record<string, string | null | undefined>,
|
||||||
) {
|
) {
|
||||||
const template = await this.whatsappTemplateService.getTemplateById(templateId);
|
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');
|
||||||
|
|
||||||
|
if (template.meta_template_name && template.meta_status === 'APPROVED') {
|
||||||
|
await this.sendTemplateMessage(to, template, variables);
|
||||||
|
} else {
|
||||||
const renderedContent = this.renderTemplateContent(template.content, variables);
|
const renderedContent = this.renderTemplateContent(template.content, variables);
|
||||||
await this.sendMessage(to, renderedContent);
|
await this.sendMessage(to, renderedContent);
|
||||||
|
}
|
||||||
|
|
||||||
const assignment = await this.assignmentService.assignChat(to, userId, areaId || null);
|
const assignment = await this.assignmentService.assignChat(to, userId, areaId || null);
|
||||||
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to);
|
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chatId: to,
|
chatId: to,
|
||||||
template: { ...template, content: renderedContent },
|
template,
|
||||||
messageId: null,
|
messageId: null,
|
||||||
assignment: lockedAssignment || assignment,
|
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>) {
|
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
|
||||||
return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => {
|
return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => {
|
||||||
const cleanKey = String(key || '').trim();
|
const cleanKey = String(key || '').trim();
|
||||||
@ -209,10 +281,15 @@ export class WhatsappService {
|
|||||||
|
|
||||||
for (const entry of body?.entry ?? []) {
|
for (const entry of body?.entry ?? []) {
|
||||||
for (const change of entry?.changes ?? []) {
|
for (const change of entry?.changes ?? []) {
|
||||||
if (change.field !== 'messages') continue;
|
|
||||||
|
|
||||||
const value = change.value;
|
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) {
|
if (value?.messages?.length) {
|
||||||
for (const message of value.messages) {
|
for (const message of value.messages) {
|
||||||
await this.handleMetaIncomingMessage(message, value.contacts ?? []);
|
await this.handleMetaIncomingMessage(message, value.contacts ?? []);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user