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

762 lines
26 KiB
TypeScript
Raw Normal View History

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Client, LocalAuth, MessageMedia } from 'whatsapp-web.js';
import { WhatsappGateway } from './whatsapp.gateway';
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
import { DatabaseService } from '../../infra/database/database.service';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class WhatsappService implements OnModuleInit {
private client: Client;
private readonly logger = new Logger(WhatsappService.name);
private status: 'DISCONNECTED' | 'AWAITING_QR' | 'CONNECTED' = 'DISCONNECTED';
private currentQr: string | null = null;
private readonly processedIncomingMessages = new Set<string>();
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
private readonly chatMessagesCache = new Map<string, any[]>();
constructor(
private readonly gateway: WhatsappGateway,
private readonly assignmentService: AttendanceAssignmentService,
private readonly db: DatabaseService
) {}
async onModuleInit() {
// Inicialização da tabela de templates no banco
try {
await this.db.query(`
CREATE TABLE IF NOT EXISTS whatsapp_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
content TEXT NOT NULL,
category VARCHAR(40) NOT NULL DEFAULT 'UTILITY',
2026-05-22 10:51:44 -03:00
area_id INTEGER REFERENCES areas (id) ON DELETE SET NULL,
status VARCHAR(40) NOT NULL DEFAULT 'approved',
requested_by_role VARCHAR(40),
admin_approved_at TIMESTAMP WITH TIME ZONE,
meta_submitted_at TIMESTAMP WITH TIME ZONE,
meta_approved_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`);
2026-05-22 10:51:44 -03:00
await this.db.query(`
ALTER TABLE whatsapp_templates
ADD COLUMN IF NOT EXISTS category VARCHAR(40) NOT NULL DEFAULT 'UTILITY',
2026-05-22 10:51:44 -03:00
ADD COLUMN IF NOT EXISTS area_id INTEGER REFERENCES areas (id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS status VARCHAR(40) NOT NULL DEFAULT 'approved',
ADD COLUMN IF NOT EXISTS requested_by_role VARCHAR(40),
ADD COLUMN IF NOT EXISTS admin_approved_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS meta_submitted_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS meta_approved_at TIMESTAMP WITH TIME ZONE;
`);
await this.db.query(`
INSERT INTO whatsapp_templates (name, content) VALUES
('aviso_fatura', 'Olá, {nome}. Estamos entrando em contato para lembrá-lo que a sua fatura está programada para {data}.'),
('boas_vindas', 'Olá, {nome}! Obrigado por entrar em contato conosco. Como podemos te ajudar hoje?'),
('lembrete_consulta', 'Olá, {nome}. Gostaríamos de confirmar o seu agendamento para {data}. Está confirmado?'),
('suporte_tecnico', 'Olá, {nome}. Sou o atendente e irei te auxiliar no seu suporte sob protocolo {protocolo}.')
ON CONFLICT (name) DO NOTHING;
`);
this.logger.log('Tabela de templates do WhatsApp verificada/criada com sucesso no PostgreSQL!');
} catch (err) {
this.logger.error('Erro ao verificar/criar tabela de templates no PostgreSQL:', err);
}
this.logger.log('Inicializando WhatsApp Client...');
this.client = new Client({
authStrategy: new LocalAuth({ dataPath: './whatsapp-session' }),
puppeteer: {
headless: true,
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--disable-gpu']
},
webVersionCache: {
type: 'none'
}
});
this.client.on('qr', (qr) => {
this.logger.log('QR Code recebido. Envie para o frontend.');
this.status = 'AWAITING_QR';
this.currentQr = qr;
this.gateway.emitQrCode(qr);
this.gateway.emitStatus(this.status);
});
this.client.on('ready', () => {
this.logger.log('WhatsApp Web Conectado!');
this.status = 'CONNECTED';
this.currentQr = null;
this.gateway.emitStatus(this.status);
});
this.client.on('authenticated', () => {
this.logger.log('WhatsApp Autenticado');
});
this.client.on('auth_failure', (msg) => {
this.logger.error('Falha na Autenticação do WhatsApp', msg);
this.status = 'DISCONNECTED';
this.gateway.emitStatus(this.status);
});
this.client.on('disconnected', (reason) => {
this.logger.warn('WhatsApp Desconectado', reason);
this.status = 'DISCONNECTED';
this.gateway.emitStatus(this.status);
});
// Mudar para message_create captura tanto mensagens recebidas quanto enviadas no WhatsApp
this.client.on('message_create', async (msg) => {
if (msg.from === 'status@broadcast') return;
const remoteJid = msg.id.remote || (msg.fromMe ? msg.to : msg.from);
const messageId = msg.id._serialized;
const messageBody = (msg.body || '').trim();
this.logger.log(`Mensagem registrada (fromMe: ${msg.fromMe}) remote: ${remoteJid} - ${messageBody}`);
let mediaData: any = null;
if (msg.hasMedia) {
try {
this.logger.log(`Baixando mídia em tempo real de ${msg.id._serialized}...`);
const media = await msg.downloadMedia();
if (media) {
mediaData = {
mimetype: media.mimetype,
data: media.data,
filename: media.filename || 'arquivo'
};
}
} catch (err) {
this.logger.error(`Erro ao baixar mídia em tempo real de ${msg.id._serialized}:`, err);
}
}
// Transmite a mensagem em tempo real para o frontend
this.gateway.emitNewMessage({
from: remoteJid,
body: msg.body,
timestamp: msg.timestamp,
isGroupMsg: remoteJid.endsWith('@g.us'),
id: messageId,
fromMe: msg.fromMe,
notifyName: msg['_data']?.notifyName || '',
hasMedia: msg.hasMedia,
media: mediaData
});
// Salva ou atualiza a conversa na persistência híbrida
await this.addOrUpdatePersistentChat(remoteJid, {
name: msg['_data']?.notifyName || remoteJid.split('@')[0],
preview: msg.hasMedia ? `[Mídia: ${mediaData?.filename || 'Arquivo'}]` : (msg.body || '[Mídia]'),
timestamp: msg.timestamp,
unreadCount: msg.fromMe ? 0 : 1,
lastMessageFromMe: msg.fromMe
});
if (!msg.fromMe) {
if (messageBody || msg.hasMedia) {
await this.assignmentService.markCustomerReplied(remoteJid);
}
if (!messageBody) {
this.logger.log(`Triagem ignorada para ${remoteJid}: mensagem sem texto.`);
return;
}
if (this.processedIncomingMessages.has(messageId)) {
return;
}
this.processedIncomingMessages.add(messageId);
if (this.processedIncomingMessages.size > 1000) {
const [oldest] = this.processedIncomingMessages;
this.processedIncomingMessages.delete(oldest);
}
await this.enqueueIncomingRoute(remoteJid, msg, messageBody, messageId);
}
});
this.client.initialize();
}
private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) {
const previousRoute = this.incomingRoutingQueues.get(remoteJid) || Promise.resolve();
let nextRoute: Promise<void>;
nextRoute = previousRoute
.catch(() => undefined)
.then(async () => {
try {
const routeResult = await this.assignmentService.routeIncomingMessage(
remoteJid,
messageBody,
messageId,
await this.getBotMessageVariables(remoteJid, msg),
);
if (routeResult.shouldSendBotMessage && routeResult.botMessage) {
this.logger.log(`Agente Virtual Sothis roteou ${remoteJid} para area ${routeResult.assignment?.area_nome || routeResult.assignment?.area_id}`);
await msg.reply(routeResult.botMessage);
}
} catch (err) {
this.logger.error(`Erro ao rotear conversa inicial de ${remoteJid}:`, err);
}
})
.finally(() => {
if (this.incomingRoutingQueues.get(remoteJid) === nextRoute) {
this.incomingRoutingQueues.delete(remoteJid);
}
});
this.incomingRoutingQueues.set(remoteJid, nextRoute);
await nextRoute;
}
private getPersistFilePath() {
return path.join(process.cwd(), 'whatsapp-chats-persist.json');
}
private async loadPersistentChats(): Promise<any> {
try {
const filepath = this.getPersistFilePath();
if (!fs.existsSync(filepath)) {
return {};
}
const data = fs.readFileSync(filepath, 'utf-8');
return JSON.parse(data);
} catch (err) {
this.logger.error('Erro ao ler chats persistentes:', err);
return {};
}
}
private async savePersistentChats(chats: any): Promise<void> {
try {
const filepath = this.getPersistFilePath();
fs.writeFileSync(filepath, JSON.stringify(chats, null, 2), 'utf-8');
} catch (err) {
this.logger.error('Erro ao salvar chats persistentes:', err);
}
}
private async addOrUpdatePersistentChat(chatId: string, data: { name?: string, preview?: string, timestamp?: number, unreadCount?: number, lastMessageFromMe?: boolean }) {
if (chatId === 'status@broadcast' || chatId.endsWith('@g.us')) return;
const chats = await this.loadPersistentChats();
const existing = chats[chatId] || {};
const isNumber = (val: string) => /^\d+$/.test(val);
let finalName = existing.name || data.name || chatId.split('@')[0];
if (data.name && !isNumber(data.name)) {
finalName = data.name;
}
if (isNumber(finalName) && this.status === 'CONNECTED') {
try {
const contact = await this.client.getContactById(chatId);
if (contact) {
if (contact.name && !isNumber(contact.name)) {
finalName = contact.name;
} else if (contact.pushname && !isNumber(contact.pushname)) {
finalName = contact.pushname;
}
}
} catch (err) {
// Ignorar erros na consulta do Puppeteer
}
}
chats[chatId] = {
id: {
server: chatId.split('@')[1] || 'c.us',
user: chatId.split('@')[0],
_serialized: chatId
},
name: finalName,
isGroup: false,
isReadOnly: false,
unreadCount: data.unreadCount !== undefined ? data.unreadCount : (existing.unreadCount || 0),
timestamp: data.timestamp || existing.timestamp || Math.floor(Date.now() / 1000),
archived: false,
pinned: false,
isLocked: false,
isMuted: false,
preview: data.preview || existing.preview || '',
lastMessageFromMe: data.lastMessageFromMe !== undefined ? data.lastMessageFromMe : Boolean(existing.lastMessageFromMe)
};
await this.savePersistentChats(chats);
}
getStatus() {
return this.status;
}
getCurrentQr() {
return this.currentQr;
}
async getChats() {
if (this.status !== 'CONNECTED') return [];
let liveChats: any[] = [];
try {
liveChats = await this.retryWhatsappRead(() => this.client.getChats());
} catch (err) {
if (this.isTransientWhatsappFrameError(err)) {
this.logger.warn(`WhatsApp Web ainda estabilizando ao carregar chats: ${this.getErrorMessage(err)}`);
} else {
this.logger.error('Erro ao chamar client.getChats():', err);
}
}
const persistentChatsObj = await this.loadPersistentChats();
const mergedChatsMap = new Map<string, any>();
// Adiciona os chats persistentes locais primeiro
Object.values(persistentChatsObj).forEach((c: any) => {
mergedChatsMap.set(c.id._serialized, c);
});
// Adiciona/Mescla com os chats em tempo real do Puppeteer
liveChats.forEach((c: any) => {
if (!c.isGroup && c.id.server === 'c.us') {
const serializedId = c.id._serialized;
const existingPersistent = persistentChatsObj[serializedId] || {};
const isNumber = (val: string) => /^\d+$/.test(val);
let finalName = c.name || existingPersistent.name || c.id.user;
if (isNumber(c.name) && existingPersistent.name && !isNumber(existingPersistent.name)) {
finalName = existingPersistent.name;
}
mergedChatsMap.set(serializedId, {
id: c.id,
name: finalName,
isGroup: c.isGroup,
isReadOnly: c.isReadOnly || false,
unreadCount: c.unreadCount !== undefined ? c.unreadCount : (existingPersistent.unreadCount || 0),
timestamp: c.timestamp || existingPersistent.timestamp || Math.floor(Date.now() / 1000),
archived: c.archived || false,
pinned: c.pinned || false,
isLocked: c.isLocked || false,
isMuted: c.isMuted || false,
preview: c.lastMessage ? (c.lastMessage.body || '[Mídia]') : (existingPersistent.preview || ''),
lastMessageFromMe: c.lastMessage?.fromMe !== undefined ? Boolean(c.lastMessage.fromMe) : Boolean(existingPersistent.lastMessageFromMe)
});
}
});
const conversas = Array.from(mergedChatsMap.values());
// Ordenar chats pelo timestamp mais recente
conversas.sort((a, b) => b.timestamp - a.timestamp);
// Buscar todas as atribuições para enriquecer os chats
return Promise.all(conversas.map(async chat => {
const assignment = await this.assignmentService.getAssignment(chat.id._serialized);
const contactProfile = await this.getCustomerContact(chat.id._serialized);
const phone = contactProfile?.phone || await this.resolveContactPhone(chat.id._serialized);
return {
...chat,
name: contactProfile?.name || chat.name,
contactProfile: contactProfile
? { ...contactProfile, phone }
: {
chat_id: chat.id._serialized,
phone,
name: null,
company: null,
note: null,
},
assignment: assignment || null
};
}));
}
private async resolveContactPhone(chatId: string) {
try {
const lidPhone = await this.resolveLidPhone(chatId);
if (lidPhone) return lidPhone;
const contact = await this.retryWhatsappRead(() => this.client.getContactById(chatId));
const phone =
(contact as any)?.number ||
(contact as any)?.phoneNumber ||
(contact as any)?.id?.user ||
'';
if (phone && !String(phone).includes('@') && !chatId.includes(`${phone}@lid`)) {
return String(phone);
}
const formattedNumber = await contact.getFormattedNumber().catch(() => '');
return formattedNumber || (chatId.endsWith('@lid') ? '' : chatId.split('@')[0]);
} catch {
return chatId.endsWith('@lid') ? '' : chatId.split('@')[0];
}
}
private async resolveLidPhone(chatId: string) {
if (!chatId.endsWith('@lid')) return '';
try {
const page = (this.client as any).pupPage;
if (!page) return '';
const phone = await this.retryWhatsappRead(() =>
page.evaluate(async (serializedChatId: string) => {
try {
const result = await (window as any).WWebJS?.enforceLidAndPnRetrieval?.(serializedChatId);
return result?.phone?._serialized || result?.phone?.user || '';
} catch {
return '';
}
}, chatId),
);
return phone ? String(phone).split('@')[0] : '';
} catch {
return '';
}
}
private async getCustomerContact(chatId: string) {
try {
const result = await this.db.query(
`
SELECT chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
FROM agenda_contatos
WHERE chat_id = $1
LIMIT 1
`,
[chatId],
);
return result.rows[0] || null;
} catch {
return null;
}
}
private async getBotMessageVariables(chatId: string, msg: any) {
const contactProfile = await this.getCustomerContact(chatId);
const notifyName = String(msg?.['_data']?.notifyName || '').trim();
const pushName = String(msg?.['_data']?.pushName || '').trim();
const phone = contactProfile?.phone || await this.resolveContactPhone(chatId);
const fallbackName = notifyName || pushName || '';
return {
nome: contactProfile?.name || fallbackName,
telefone: phone,
};
}
async getChatMessages(chatId: string) {
if (this.status !== 'CONNECTED') return [];
try {
const messages = await this.retryWhatsappRead(async () => {
const chat = await this.client.getChatById(chatId);
return chat.fetchMessages({ limit: 50 });
});
this.chatMessagesCache.set(chatId, messages);
return messages;
} catch (err) {
const cachedMessages = this.chatMessagesCache.get(chatId) || [];
if (this.isTransientWhatsappFrameError(err)) {
this.logger.warn(`WhatsApp Web ainda estabilizando ao carregar mensagens de ${chatId}. Retornando cache local (${cachedMessages.length}).`);
} else {
this.logger.error(`Erro ao carregar mensagens de ${chatId}. Retornando cache local (${cachedMessages.length}).`, err);
}
return cachedMessages;
}
}
async getMessageMedia(chatId: string, messageId: string) {
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
this.logger.log(`Buscando mídia do chat ${chatId}, mensagem ${messageId}`);
const messages = await this.retryWhatsappRead(async () => {
const chat = await this.client.getChatById(chatId);
return chat.fetchMessages({ limit: 50 });
});
const msg = messages.find(m => m.id._serialized === messageId);
if (msg && msg.hasMedia) {
this.logger.log(`Baixando mídia para mensagem ${messageId}...`);
const media = await msg.downloadMedia();
if (media) {
return {
mimetype: media.mimetype,
data: media.data,
filename: media.filename || 'arquivo'
};
}
}
throw new Error('Mídia não encontrada para esta mensagem');
}
private async retryWhatsappRead<T>(operation: () => Promise<T>, retries = 1): Promise<T> {
try {
return await operation();
} catch (err) {
if (retries <= 0 || !this.isTransientWhatsappFrameError(err)) {
throw err;
}
await this.sleep(350);
return this.retryWhatsappRead(operation, retries - 1);
}
}
private sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private isTransientWhatsappFrameError(err: unknown) {
const message = this.getErrorMessage(err).toLowerCase();
return (
message.includes('detached frame') ||
message.includes('execution context was destroyed') ||
message.includes('most likely because of a navigation')
);
}
private getErrorMessage(err: unknown) {
if (err instanceof Error) return err.message;
return String(err || '');
}
async sendMessage(to: string, message: string, media?: { data: string; mimetype: string; filename?: string }, senderName?: string) {
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
const canSendMessage = await this.assignmentService.canSendAgentMessage(to);
if (!canSendMessage) {
throw new Error('Aguarde o cliente responder antes de enviar novas mensagens.');
}
const outboundMessage = this.formatOutboundMessage(message, senderName);
let sentMsg;
if (media) {
this.logger.log(`Enviando mídia para ${to}: ${media.filename} (${media.mimetype})`);
const messageMedia = new MessageMedia(media.mimetype, media.data, media.filename);
sentMsg = await this.client.sendMessage(to, messageMedia, { caption: outboundMessage });
} else {
sentMsg = await this.client.sendMessage(to, outboundMessage);
}
// Sincronizar na persistência também!
await this.addOrUpdatePersistentChat(to, {
name: to.split('@')[0],
preview: media ? `[Mídia: ${media.filename || 'Arquivo'}]` : message,
timestamp: Math.floor(Date.now() / 1000),
unreadCount: 0,
lastMessageFromMe: true
});
await this.assignmentService.clearTransferNote(to);
return sentMsg;
}
async startAttendance(
to: string,
templateId: number,
userId: number,
areaId?: number | null,
variables?: Record<string, string | null | undefined>,
) {
const template = await this.getTemplateById(templateId);
if (!template) {
throw new Error('Template de WhatsApp nao encontrado');
}
const renderedContent = this.renderTemplateContent(template.content, variables);
const sentMessage = await this.sendMessage(to, renderedContent);
const chatId = this.getSentMessageChatId(sentMessage, to);
const assignment = await this.assignmentService.assignChat(chatId, userId, areaId || null);
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(chatId);
return {
chatId,
template: { ...template, content: renderedContent },
messageId: sentMessage?.id?._serialized || null,
assignment: lockedAssignment || assignment,
};
}
private getSentMessageChatId(sentMessage: any, fallbackChatId: string) {
const remote =
sentMessage?.id?.remote ||
sentMessage?._data?.id?.remote ||
sentMessage?.to ||
sentMessage?.from ||
fallbackChatId;
return String(remote || fallbackChatId);
}
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => {
const cleanKey = String(key || '').trim();
const lowerKey = cleanKey.toLowerCase();
const asciiKey = lowerKey.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const value = variables?.[cleanKey] ?? variables?.[lowerKey] ?? variables?.[asciiKey];
const normalized = String(value || '').trim();
return normalized || match;
});
}
private formatOutboundMessage(message: string, senderName?: string) {
const cleanMessage = (message || '').trim();
const cleanSenderName = (senderName || '').trim();
if (!cleanSenderName) {
return cleanMessage;
}
if (!cleanMessage) {
return '';
}
return `*Atendente: ${cleanSenderName}*\n\n${cleanMessage}`;
}
async getTemplates() {
await this.refreshMetaApprovals();
2026-05-22 10:51:44 -03:00
const res = await this.db.query(`
SELECT
wt.*,
a.nome AS area_nome
FROM whatsapp_templates wt
LEFT JOIN areas a ON a.id = wt.area_id
ORDER BY wt.id ASC
`);
return res.rows;
}
private async getTemplateById(id: number) {
await this.refreshMetaApprovals();
const res = await this.db.query('SELECT * FROM whatsapp_templates WHERE id = $1 LIMIT 1', [id]);
return res.rows[0] || null;
}
async saveTemplate(name: string, content: string, areaId?: number | null, requestedByRole = 'admin', category = 'UTILITY') {
2026-05-22 10:51:44 -03:00
const isSupervisor = requestedByRole === 'supervisor';
const status = isSupervisor ? 'admin_review' : 'meta_review';
const adminApprovedAt = isSupervisor ? null : 'CURRENT_TIMESTAMP';
const metaSubmittedAt = isSupervisor ? null : 'CURRENT_TIMESTAMP';
const res = await this.db.query(
`
INSERT INTO whatsapp_templates (
name,
content,
category,
2026-05-22 10:51:44 -03:00
area_id,
status,
requested_by_role,
admin_approved_at,
meta_submitted_at,
meta_approved_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, ${adminApprovedAt}, ${metaSubmittedAt}, NULL, CURRENT_TIMESTAMP)
2026-05-22 10:51:44 -03:00
ON CONFLICT (name) DO UPDATE SET
content = EXCLUDED.content,
category = EXCLUDED.category,
2026-05-22 10:51:44 -03:00
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
RETURNING *
`,
[name, content, this.normalizeTemplateCategory(category), areaId || null, status, requestedByRole]
2026-05-22 10:51:44 -03:00
);
return res.rows[0];
}
async updateTemplate(id: number, name: string, content: string, areaId?: number | null, category = 'UTILITY') {
2026-05-22 10:51:44 -03:00
const res = await this.db.query(
`
UPDATE whatsapp_templates
SET
name = $1,
content = $2,
category = $3,
area_id = $4,
2026-05-22 10:51:44 -03:00
status = 'meta_review',
admin_approved_at = CURRENT_TIMESTAMP,
meta_submitted_at = CURRENT_TIMESTAMP,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $5
2026-05-22 10:51:44 -03:00
RETURNING *
`,
[name, content, this.normalizeTemplateCategory(category), areaId || null, id]
2026-05-22 10:51:44 -03:00
);
return res.rows[0];
}
async approveTemplateByAdmin(id: number) {
const res = await this.db.query(
2026-05-22 10:51:44 -03:00
`
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],
);
return res.rows[0];
}
2026-05-22 10:51:44 -03:00
async rejectTemplateByAdmin(id: number) {
const res = await this.db.query(
2026-05-22 10:51:44 -03:00
`
UPDATE whatsapp_templates
SET
status = 'rejected',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *
`,
[id],
);
return res.rows[0];
}
2026-05-22 10:51:44 -03:00
async deleteTemplate(id: number) {
await this.db.query('DELETE FROM whatsapp_templates WHERE id = $1', [id]);
return { success: true };
}
private normalizeTemplateCategory(category?: string) {
const normalized = String(category || 'UTILITY').trim().toUpperCase();
return ['UTILITY', 'MARKETING', 'AUTHENTICATION'].includes(normalized) ? normalized : 'UTILITY';
}
private async refreshMetaApprovals() {
2026-05-22 10:51:44 -03:00
await this.db.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'
`);
}
}