REFACTOR: Organizado fluxo de WhatsApp e atendimento

- Templates de WhatsApp agora são gerenciados por um repositório e serviçodedicado.
- DTOS para WhatsApp foram criados para melhor estruturação dos dados.
- AtteandanceAssigmentRepository foi criado para gerenciar as atribuições de atendimento.
- Db Query focados apenas em repositorios, deixando os serviços mais limpos e focados na lógica de negócio.
- Encodings corrigidos.
This commit is contained in:
Rafael Alves Lopes 2026-06-01 15:35:40 -03:00
parent 1f5d84e065
commit 6097f730b7
9 changed files with 904 additions and 639 deletions

View File

@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DatabaseService } from '../../infra/database/database.service';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { AgentPresenceService } from '../admin/agent-presence.service';
import { AttendanceAssignmentRepository } from './repositories/attendance-assignment.repository';
interface TransferInput {
chatId: string;
@ -116,113 +116,23 @@ const SALES_KEYWORDS = [
];
@Injectable()
export class AttendanceAssignmentService implements OnModuleInit {
export class AttendanceAssignmentService {
private readonly logger = new Logger(AttendanceAssignmentService.name);
constructor(
private readonly db: DatabaseService,
private readonly attendanceAssignmentRepository: AttendanceAssignmentRepository,
private readonly agentPresenceService: AgentPresenceService,
) {}
async onModuleInit() {
await this.ensureSchema();
}
async ensureSchema() {
await this.db.query(`
ALTER TABLE whatsapp_chat_atribuicoes
ALTER COLUMN user_id DROP NOT NULL,
ALTER COLUMN area_id DROP NOT NULL;
`).catch(() => undefined);
await this.db.query(`
ALTER TABLE whatsapp_chat_atribuicoes
ADD COLUMN IF NOT EXISTS status VARCHAR(40) NOT NULL DEFAULT 'assigned',
ADD COLUMN IF NOT EXISTS conversation_started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP WITH TIME ZONE DEFAULT (CURRENT_TIMESTAMP + INTERVAL '24 hours'),
ADD COLUMN IF NOT EXISTS transfer_note TEXT,
ADD COLUMN IF NOT EXISTS routing_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS last_routed_message_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS last_bot_sent_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS awaiting_customer_reply BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS reserved_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS pause_released_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS triage_flow_id INTEGER,
ADD COLUMN IF NOT EXISTS triage_audience_id INTEGER,
ADD COLUMN IF NOT EXISTS triage_intent_id INTEGER,
ADD COLUMN IF NOT EXISTS triage_step VARCHAR(40),
ADD COLUMN IF NOT EXISTS triage_builder_version_id INTEGER,
ADD COLUMN IF NOT EXISTS triage_builder_node_id INTEGER,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
`);
}
async assignChat(chatId: string, userId: string | number, areaId?: string | number | null) {
this.logger.log(`Atribuindo chat ${chatId} ao usuario ${userId}`);
await this.assertUserCanReceiveAssignment(Number(userId));
const query = `
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, conversation_started_at, expires_at, assigned_at, updated_at
)
VALUES ($1, $2, $3, 'assigned', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
area_id = COALESCE(EXCLUDED.area_id, whatsapp_chat_atribuicoes.area_id),
status = 'assigned',
awaiting_customer_reply = FALSE,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
assigned_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`;
const result = await this.db.query(query, [chatId, Number(userId), areaId ? Number(areaId) : null]);
const result = await this.attendanceAssignmentRepository.assignChat(chatId, Number(userId), areaId ? Number(areaId) : null);
return this.enrichAssignment(result.rows[0]);
}
async queueChat(chatId: string, areaId: number, note?: string | null) {
const query = `
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, conversation_started_at, expires_at, transfer_note, assigned_at, updated_at
)
VALUES ($1, NULL, $2, 'queued', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', $3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = NULL,
area_id = EXCLUDED.area_id,
status = 'queued',
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
conversation_started_at = CASE
WHEN whatsapp_chat_atribuicoes.expires_at <= CURRENT_TIMESTAMP THEN CURRENT_TIMESTAMP
ELSE whatsapp_chat_atribuicoes.conversation_started_at
END,
expires_at = CASE
WHEN whatsapp_chat_atribuicoes.expires_at <= CURRENT_TIMESTAMP THEN CURRENT_TIMESTAMP + INTERVAL '24 hours'
ELSE whatsapp_chat_atribuicoes.expires_at
END,
transfer_note = EXCLUDED.transfer_note,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`;
const result = await this.db.query(query, [chatId, areaId, note || null]);
const result = await this.attendanceAssignmentRepository.queueChat(chatId, areaId, note || null);
return this.enrichAssignment(result.rows[0]);
}
@ -232,47 +142,18 @@ export class AttendanceAssignmentService implements OnModuleInit {
await this.assertUserCanReceiveAssignment(input.userId);
}
const query = `
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, conversation_started_at, expires_at, transfer_note, assigned_at, updated_at
)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
area_id = EXCLUDED.area_id,
status = EXCLUDED.status,
transfer_note = EXCLUDED.transfer_note,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
assigned_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`;
const result = await this.db.query(query, [
input.chatId,
input.userId || null,
input.areaId,
const result = await this.attendanceAssignmentRepository.transferChat({
chatId: input.chatId,
userId: input.userId || null,
areaId: input.areaId,
status,
input.note || null,
]);
note: input.note || null,
});
return this.enrichAssignment(result.rows[0]);
}
async getAssignment(chatId: string) {
const query = `
SELECT * FROM whatsapp_chat_atribuicoes
WHERE chat_id = $1
LIMIT 1
`;
const result = await this.db.query(query, [chatId]);
const result = await this.attendanceAssignmentRepository.getAssignment(chatId);
const assignment = result.rows[0];
if (!assignment) return null;
@ -282,10 +163,7 @@ export class AttendanceAssignmentService implements OnModuleInit {
}
if (assignment.expires_at && new Date(assignment.expires_at).getTime() <= Date.now()) {
await this.db.query(
`UPDATE whatsapp_chat_atribuicoes SET status = 'expired', user_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE chat_id = $1`,
[chatId],
);
await this.attendanceAssignmentRepository.expireAssignment(chatId);
return null;
}
@ -294,102 +172,31 @@ export class AttendanceAssignmentService implements OnModuleInit {
async releaseChat(chatId: string) {
this.logger.log(`Liberando chat ${chatId}`);
const query = `
UPDATE whatsapp_chat_atribuicoes
SET
user_id = NULL,
status = 'queued',
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
RETURNING *;
`;
const result = await this.db.query(query, [chatId]);
const result = await this.attendanceAssignmentRepository.releaseChat(chatId);
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
}
async closeChat(chatId: string, userId?: string | number | null) {
this.logger.log(`Encerrando chat ${chatId}`);
const params: Array<string | number | null> = [chatId];
const userGuard = userId ? 'AND (user_id = $2 OR user_id IS NULL)' : '';
if (userId) params.push(Number(userId));
const result = await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
user_id = NULL,
area_id = NULL,
status = 'expired',
awaiting_customer_reply = FALSE,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
transfer_note = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
${userGuard}
RETURNING *
`,
params,
);
const result = await this.attendanceAssignmentRepository.closeChat(chatId, userId);
return result.rows[0] || null;
}
async clearTransferNote(chatId: string) {
const result = await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET transfer_note = NULL, updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
RETURNING *
`,
[chatId],
);
const result = await this.attendanceAssignmentRepository.clearTransferNote(chatId);
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
}
async markAwaitingCustomerReply(chatId: string) {
const result = await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET awaiting_customer_reply = TRUE, updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
RETURNING *
`,
[chatId],
);
const result = await this.attendanceAssignmentRepository.markAwaitingCustomerReply(chatId);
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
}
async markCustomerReplied(chatId: string) {
const result = await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET awaiting_customer_reply = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1 AND awaiting_customer_reply = TRUE
RETURNING *
`,
[chatId],
);
const result = await this.attendanceAssignmentRepository.markCustomerReplied(chatId);
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
}
@ -506,14 +313,12 @@ export class AttendanceAssignmentService implements OnModuleInit {
}
async getChatsByArea(areaId: string) {
const query = `SELECT * FROM whatsapp_chat_atribuicoes WHERE area_id = $1 AND status <> 'expired'`;
const result = await this.db.query(query, [areaId]);
const result = await this.attendanceAssignmentRepository.getChatsByArea(areaId);
return result.rows;
}
async getChatsByUser(userId: string) {
const query = `SELECT * FROM whatsapp_chat_atribuicoes WHERE user_id = $1 AND status = 'assigned'`;
const result = await this.db.query(query, [userId]);
const result = await this.attendanceAssignmentRepository.getChatsByUser(userId);
return result.rows;
}
@ -570,20 +375,7 @@ export class AttendanceAssignmentService implements OnModuleInit {
const matchedChild = this.matchBuilderChild(message, currentNode.children || []);
if (matchedChild) {
if (matchedChild.node_type === 'close') {
await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET status = 'expired',
user_id = NULL,
area_id = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
last_routed_message_id = $2,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
`,
[chatId, messageId || null],
);
await this.attendanceAssignmentRepository.closeBotConversation(chatId, messageId);
return {
assignment: null,
@ -856,24 +648,7 @@ export class AttendanceAssignmentService implements OnModuleInit {
normalized.includes('obrigado');
if (wantsClose && !wantsEscalation) {
await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET status = 'expired',
user_id = NULL,
area_id = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
last_routed_message_id = $2,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
`,
[chatId, messageId || null],
);
await this.attendanceAssignmentRepository.closeBotConversation(chatId, messageId);
const assignment = await this.getAssignment(chatId);
return {
assignment,
@ -903,7 +678,7 @@ export class AttendanceAssignmentService implements OnModuleInit {
botMessage: this.formatSenderMessage(
'Atendente virtual',
VIRTUAL_AGENT_NAME,
intent.escalation_message || 'Certo, vou encaminhar seu atendimento para o time responsável.',
intent.escalation_message || 'Certo, vou encaminhar seu atendimento para o time responsavel.',
),
};
}
@ -968,43 +743,14 @@ export class AttendanceAssignmentService implements OnModuleInit {
}
private async getActiveTriageFlow(): Promise<TriageFlow | null> {
const flows = await this.db.query<any>(
`
SELECT *
FROM bot_triage_flows
WHERE active = TRUE
ORDER BY id ASC
LIMIT 1
`,
).catch(() => ({ rows: [] }));
const flows = await this.attendanceAssignmentRepository.getActiveTriageFlow().catch(() => ({ rows: [] }));
const flow = flows.rows[0];
if (!flow) return null;
const audiences = await this.db.query<any>(
`
SELECT *
FROM bot_triage_audiences
WHERE flow_id = $1
AND active = TRUE
ORDER BY sort_order ASC, id ASC
`,
[flow.id],
);
const audiences = await this.attendanceAssignmentRepository.listActiveTriageAudiences(flow.id);
const intents = await this.db.query<any>(
`
SELECT
bti.*,
a.nome AS area_nome
FROM bot_triage_intents bti
INNER JOIN areas a ON a.id = bti.area_id
WHERE bti.audience_id = ANY($1::int[])
AND bti.active = TRUE
ORDER BY bti.sort_order ASC, bti.id ASC
`,
[audiences.rows.map((audience) => audience.id)],
);
const intents = await this.attendanceAssignmentRepository.listActiveTriageIntents(audiences.rows.map((audience) => audience.id));
return {
...flow,
@ -1016,34 +762,12 @@ export class AttendanceAssignmentService implements OnModuleInit {
}
private async getPublishedBotFlow(): Promise<BotFlowVersion | null> {
const versionResult = await this.db.query<any>(
`
SELECT *
FROM bot_flow_versions
WHERE status = 'published'
AND root_node_id IS NOT NULL
ORDER BY published_at DESC, id DESC
LIMIT 1
`,
).catch(() => ({ rows: [] }));
const versionResult = await this.attendanceAssignmentRepository.getPublishedBotFlowVersion().catch(() => ({ rows: [] }));
const version = versionResult.rows[0];
if (!version) return null;
const nodeResult = await this.db.query<any>(
`
SELECT
node.*,
area.nome AS area_nome,
fallback_area.nome AS fallback_area_nome
FROM bot_flow_nodes node
LEFT JOIN areas area ON area.id = node.area_id
LEFT JOIN areas fallback_area ON fallback_area.id = node.fallback_area_id
WHERE node.version_id = $1
ORDER BY node.parent_id NULLS FIRST, node.sort_order ASC, node.id ASC
`,
[version.id],
).catch(() => ({ rows: [] }));
const nodeResult = await this.attendanceAssignmentRepository.listBotFlowNodes(version.id).catch(() => ({ rows: [] }));
const byId = new Map<number, BotFlowNode>();
nodeResult.rows.forEach((node) => byId.set(Number(node.id), { ...node, children: [] }));
@ -1097,16 +821,7 @@ export class AttendanceAssignmentService implements OnModuleInit {
}
private async detectConfiguredArea(normalizedMessage: string) {
const result = await this.db.query<{ id: number; nome: string; keyword: string }>(
`
SELECT a.id, a.nome, ark.keyword
FROM area_routing_keywords ark
INNER JOIN areas a ON a.id = ark.area_id
WHERE ark.active = TRUE
AND a.ativo = TRUE
ORDER BY LENGTH(ark.keyword) DESC
`,
).catch(() => ({ rows: [] }));
const result = await this.attendanceAssignmentRepository.listRoutingKeywords().catch(() => ({ rows: [] }));
const match = result.rows.find((row) => normalizedMessage.includes(this.normalize(row.keyword)));
if (!match) return null;
@ -1126,10 +841,7 @@ export class AttendanceAssignmentService implements OnModuleInit {
}
private async getAreaByName(targetName: string) {
const result = await this.db.query<{ id: number; nome: string }>(
`SELECT id, nome FROM areas WHERE nome = $1 LIMIT 1`,
[targetName],
);
const result = await this.attendanceAssignmentRepository.getAreaByName(targetName);
if (result.rows[0]) {
return {
@ -1143,27 +855,7 @@ export class AttendanceAssignmentService implements OnModuleInit {
}
private async upsertTriage(chatId: string, attempts: number, messageId?: string) {
const query = `
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, routing_attempts, last_routed_message_id,
last_bot_sent_at, conversation_started_at, expires_at, assigned_at, updated_at
)
VALUES (
$1, NULL, NULL, 'bot_triage', $2, $3,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = NULL,
area_id = NULL,
status = 'bot_triage',
routing_attempts = EXCLUDED.routing_attempts,
last_routed_message_id = EXCLUDED.last_routed_message_id,
last_bot_sent_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`;
const result = await this.db.query(query, [chatId, attempts, messageId || null]);
const result = await this.attendanceAssignmentRepository.upsertTriage(chatId, attempts, messageId);
return this.enrichAssignment(result.rows[0]);
}
@ -1176,44 +868,17 @@ export class AttendanceAssignmentService implements OnModuleInit {
messageId?: string,
intentId: number | null = null,
) {
const query = `
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, routing_attempts, last_routed_message_id,
last_bot_sent_at, triage_flow_id, triage_audience_id, triage_intent_id, triage_step,
conversation_started_at, expires_at, assigned_at, updated_at
)
VALUES (
$1, NULL, NULL, 'bot_triage', $2, $3,
CURRENT_TIMESTAMP, $4, $5, $6, $7,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = NULL,
area_id = NULL,
status = 'bot_triage',
routing_attempts = EXCLUDED.routing_attempts,
last_routed_message_id = EXCLUDED.last_routed_message_id,
last_bot_sent_at = CURRENT_TIMESTAMP,
triage_flow_id = EXCLUDED.triage_flow_id,
triage_audience_id = EXCLUDED.triage_audience_id,
triage_intent_id = EXCLUDED.triage_intent_id,
triage_step = EXCLUDED.triage_step,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`;
const result = await this.db.query(query, [
const result = await this.attendanceAssignmentRepository.upsertConfiguredTriage({
chatId,
attempts,
messageId || null,
flowId,
audienceId,
intentId,
step,
]);
attempts,
messageId,
intentId,
});
return this.enrichAssignment(result.rows[0]);
}
private async upsertBuilderTriage(
chatId: string,
versionId: number,
@ -1221,72 +886,16 @@ export class AttendanceAssignmentService implements OnModuleInit {
attempts: number,
messageId?: string,
) {
const query = `
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, routing_attempts, last_routed_message_id,
last_bot_sent_at, triage_builder_version_id, triage_builder_node_id,
conversation_started_at, expires_at, assigned_at, updated_at
)
VALUES (
$1, NULL, NULL, 'bot_triage', $2, $3,
CURRENT_TIMESTAMP, $4, $5,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = NULL,
area_id = NULL,
status = 'bot_triage',
routing_attempts = EXCLUDED.routing_attempts,
last_routed_message_id = EXCLUDED.last_routed_message_id,
last_bot_sent_at = CURRENT_TIMESTAMP,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = EXCLUDED.triage_builder_version_id,
triage_builder_node_id = EXCLUDED.triage_builder_node_id,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`;
const result = await this.db.query(query, [
chatId,
attempts,
messageId || null,
versionId,
nodeId,
]);
const result = await this.attendanceAssignmentRepository.upsertBuilderTriage(chatId, versionId, nodeId, attempts, messageId);
return this.enrichAssignment(result.rows[0]);
}
private async markBotRoute(chatId: string, messageId?: string, updateBotTimestamp = true) {
await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
routing_attempts = 0,
last_routed_message_id = $2,
last_bot_sent_at = CASE WHEN $3 THEN CURRENT_TIMESTAMP ELSE last_bot_sent_at END,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
`,
[chatId, messageId || null, updateBotTimestamp],
);
await this.attendanceAssignmentRepository.markBotRoute(chatId, messageId, updateBotTimestamp);
}
private async markMessageRouted(chatId: string, messageId?: string) {
await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
last_routed_message_id = $2,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
`,
[chatId, messageId || null],
);
await this.attendanceAssignmentRepository.markMessageRouted(chatId, messageId);
}
private matchAny(text: string, keywords: string[]) {
return keywords.some((keyword) => text.includes(this.normalize(keyword)));
}
@ -1382,7 +991,7 @@ export class AttendanceAssignmentService implements OnModuleInit {
if (!lastBotSentAt) return false;
const normalized = this.normalize(message).replace(/[!?.,\s]+/g, ' ').trim();
const greetingOnlyMessages = ['oi', 'ola', 'olá', 'bom dia', 'boa tarde', 'boa noite'];
const greetingOnlyMessages = ['oi', 'ola', 'olá', 'bom dia', 'boa tarde', 'boa noite'];
if (!greetingOnlyMessages.includes(normalized)) return false;
const lastBotTime = new Date(lastBotSentAt).getTime();
@ -1398,21 +1007,7 @@ export class AttendanceAssignmentService implements OnModuleInit {
private async enrichAssignment(assignment: any) {
if (!assignment) return null;
const result = await this.db.query(
`
SELECT
wca.*,
a.nome AS area_nome,
u.nome AS user_nome,
u.email AS user_email
FROM whatsapp_chat_atribuicoes wca
LEFT JOIN areas a ON a.id = wca.area_id
LEFT JOIN usuarios u ON u.id = wca.user_id
WHERE wca.id = $1
`,
[assignment.id],
);
const result = await this.attendanceAssignmentRepository.enrichAssignment(assignment.id);
return result.rows[0] || assignment;
}
}

View File

@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module';
import { AttendanceAssignmentService } from './attendance-assignment.service';
import { AttendanceAssignmentRepository } from './repositories/attendance-assignment.repository';
@Module({
imports: [AdminModule],
providers: [AttendanceAssignmentService],
providers: [AttendanceAssignmentService, AttendanceAssignmentRepository],
exports: [AttendanceAssignmentService],
})
export class AttendanceModule {}

View File

@ -0,0 +1,473 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../../infra/database/database.service';
@Injectable()
export class AttendanceAssignmentRepository {
constructor(private readonly database: DatabaseService) {}
assignChat(chatId: string, userId: number, areaId?: number | null) {
return this.database.query(
`
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, conversation_started_at, expires_at, assigned_at, updated_at
)
VALUES ($1, $2, $3, 'assigned', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
area_id = COALESCE(EXCLUDED.area_id, whatsapp_chat_atribuicoes.area_id),
status = 'assigned',
awaiting_customer_reply = FALSE,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
assigned_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`,
[chatId, userId, areaId || null],
);
}
queueChat(chatId: string, areaId: number, note?: string | null) {
return this.database.query(
`
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, conversation_started_at, expires_at, transfer_note, assigned_at, updated_at
)
VALUES ($1, NULL, $2, 'queued', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', $3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = NULL,
area_id = EXCLUDED.area_id,
status = 'queued',
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
conversation_started_at = CASE
WHEN whatsapp_chat_atribuicoes.expires_at <= CURRENT_TIMESTAMP THEN CURRENT_TIMESTAMP
ELSE whatsapp_chat_atribuicoes.conversation_started_at
END,
expires_at = CASE
WHEN whatsapp_chat_atribuicoes.expires_at <= CURRENT_TIMESTAMP THEN CURRENT_TIMESTAMP + INTERVAL '24 hours'
ELSE whatsapp_chat_atribuicoes.expires_at
END,
transfer_note = EXCLUDED.transfer_note,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`,
[chatId, areaId, note || null],
);
}
transferChat(input: { chatId: string; userId?: number | null; areaId: number; status: string; note?: string | null }) {
return this.database.query(
`
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, conversation_started_at, expires_at, transfer_note, assigned_at, updated_at
)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
area_id = EXCLUDED.area_id,
status = EXCLUDED.status,
transfer_note = EXCLUDED.transfer_note,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
assigned_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`,
[input.chatId, input.userId || null, input.areaId, input.status, input.note || null],
);
}
getAssignment(chatId: string) {
return this.database.query(
`
SELECT * FROM whatsapp_chat_atribuicoes
WHERE chat_id = $1
LIMIT 1
`,
[chatId],
);
}
expireAssignment(chatId: string) {
return this.database.query(
`UPDATE whatsapp_chat_atribuicoes SET status = 'expired', user_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE chat_id = $1`,
[chatId],
);
}
releaseChat(chatId: string) {
return this.database.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
user_id = NULL,
status = 'queued',
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
RETURNING *;
`,
[chatId],
);
}
closeChat(chatId: string, userId?: string | number | null) {
const params: Array<string | number | null> = [chatId];
const userGuard = userId ? 'AND (user_id = $2 OR user_id IS NULL)' : '';
if (userId) params.push(Number(userId));
return this.database.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
user_id = NULL,
area_id = NULL,
status = 'expired',
awaiting_customer_reply = FALSE,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
transfer_note = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
${userGuard}
RETURNING *
`,
params,
);
}
clearTransferNote(chatId: string) {
return this.database.query(
`
UPDATE whatsapp_chat_atribuicoes
SET transfer_note = NULL, updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
RETURNING *
`,
[chatId],
);
}
markAwaitingCustomerReply(chatId: string) {
return this.database.query(
`
UPDATE whatsapp_chat_atribuicoes
SET awaiting_customer_reply = TRUE, updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
RETURNING *
`,
[chatId],
);
}
markCustomerReplied(chatId: string) {
return this.database.query(
`
UPDATE whatsapp_chat_atribuicoes
SET awaiting_customer_reply = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1 AND awaiting_customer_reply = TRUE
RETURNING *
`,
[chatId],
);
}
getChatsByArea(areaId: string) {
return this.database.query(`SELECT * FROM whatsapp_chat_atribuicoes WHERE area_id = $1 AND status <> 'expired'`, [areaId]);
}
getChatsByUser(userId: string) {
return this.database.query(`SELECT * FROM whatsapp_chat_atribuicoes WHERE user_id = $1 AND status = 'assigned'`, [userId]);
}
closeBotConversation(chatId: string, messageId?: string) {
return this.database.query(
`
UPDATE whatsapp_chat_atribuicoes
SET status = 'expired',
user_id = NULL,
area_id = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
last_routed_message_id = $2,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
`,
[chatId, messageId || null],
);
}
getActiveTriageFlow() {
return this.database.query<any>(
`
SELECT *
FROM bot_triage_flows
WHERE active = TRUE
ORDER BY id ASC
LIMIT 1
`,
);
}
listActiveTriageAudiences(flowId: number) {
return this.database.query<any>(
`
SELECT *
FROM bot_triage_audiences
WHERE flow_id = $1
AND active = TRUE
ORDER BY sort_order ASC, id ASC
`,
[flowId],
);
}
listActiveTriageIntents(audienceIds: number[]) {
return this.database.query<any>(
`
SELECT
bti.*,
a.nome AS area_nome
FROM bot_triage_intents bti
INNER JOIN areas a ON a.id = bti.area_id
WHERE bti.audience_id = ANY($1::int[])
AND bti.active = TRUE
ORDER BY bti.sort_order ASC, bti.id ASC
`,
[audienceIds],
);
}
getPublishedBotFlowVersion() {
return this.database.query<any>(
`
SELECT *
FROM bot_flow_versions
WHERE status = 'published'
AND root_node_id IS NOT NULL
ORDER BY published_at DESC, id DESC
LIMIT 1
`,
);
}
listBotFlowNodes(versionId: number) {
return this.database.query<any>(
`
SELECT
node.*,
area.nome AS area_nome,
fallback_area.nome AS fallback_area_nome
FROM bot_flow_nodes node
LEFT JOIN areas area ON area.id = node.area_id
LEFT JOIN areas fallback_area ON fallback_area.id = node.fallback_area_id
WHERE node.version_id = $1
ORDER BY node.parent_id NULLS FIRST, node.sort_order ASC, node.id ASC
`,
[versionId],
);
}
listRoutingKeywords() {
return this.database.query<{ id: number; nome: string; keyword: string }>(
`
SELECT a.id, a.nome, ark.keyword
FROM area_routing_keywords ark
INNER JOIN areas a ON a.id = ark.area_id
WHERE ark.active = TRUE
AND a.ativo = TRUE
ORDER BY LENGTH(ark.keyword) DESC
`,
);
}
getAreaByName(targetName: string) {
return this.database.query<{ id: number; nome: string }>(
`SELECT id, nome FROM areas WHERE nome = $1 LIMIT 1`,
[targetName],
);
}
upsertTriage(chatId: string, attempts: number, messageId?: string) {
return this.database.query(
`
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, routing_attempts, last_routed_message_id,
last_bot_sent_at, conversation_started_at, expires_at, assigned_at, updated_at
)
VALUES (
$1, NULL, NULL, 'bot_triage', $2, $3,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = NULL,
area_id = NULL,
status = 'bot_triage',
routing_attempts = EXCLUDED.routing_attempts,
last_routed_message_id = EXCLUDED.last_routed_message_id,
last_bot_sent_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`,
[chatId, attempts, messageId || null],
);
}
upsertConfiguredTriage(input: {
chatId: string;
flowId: number;
audienceId: number | null;
step: 'audience' | 'intent' | 'resolution';
attempts: number;
messageId?: string;
intentId: number | null;
}) {
return this.database.query(
`
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, routing_attempts, last_routed_message_id,
last_bot_sent_at, triage_flow_id, triage_audience_id, triage_intent_id, triage_step,
conversation_started_at, expires_at, assigned_at, updated_at
)
VALUES (
$1, NULL, NULL, 'bot_triage', $2, $3,
CURRENT_TIMESTAMP, $4, $5, $6, $7,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = NULL,
area_id = NULL,
status = 'bot_triage',
routing_attempts = EXCLUDED.routing_attempts,
last_routed_message_id = EXCLUDED.last_routed_message_id,
last_bot_sent_at = CURRENT_TIMESTAMP,
triage_flow_id = EXCLUDED.triage_flow_id,
triage_audience_id = EXCLUDED.triage_audience_id,
triage_intent_id = EXCLUDED.triage_intent_id,
triage_step = EXCLUDED.triage_step,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`,
[input.chatId, input.attempts, input.messageId || null, input.flowId, input.audienceId, input.intentId, input.step],
);
}
upsertBuilderTriage(chatId: string, versionId: number, nodeId: number, attempts: number, messageId?: string) {
return this.database.query(
`
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, routing_attempts, last_routed_message_id,
last_bot_sent_at, triage_builder_version_id, triage_builder_node_id,
conversation_started_at, expires_at, assigned_at, updated_at
)
VALUES (
$1, NULL, NULL, 'bot_triage', $2, $3,
CURRENT_TIMESTAMP, $4, $5,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT (chat_id) DO UPDATE SET
user_id = NULL,
area_id = NULL,
status = 'bot_triage',
routing_attempts = EXCLUDED.routing_attempts,
last_routed_message_id = EXCLUDED.last_routed_message_id,
last_bot_sent_at = CURRENT_TIMESTAMP,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = EXCLUDED.triage_builder_version_id,
triage_builder_node_id = EXCLUDED.triage_builder_node_id,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`,
[chatId, attempts, messageId || null, versionId, nodeId],
);
}
markBotRoute(chatId: string, messageId?: string, updateBotTimestamp = true) {
return this.database.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
routing_attempts = 0,
last_routed_message_id = $2,
last_bot_sent_at = CASE WHEN $3 THEN CURRENT_TIMESTAMP ELSE last_bot_sent_at END,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
`,
[chatId, messageId || null, updateBotTimestamp],
);
}
markMessageRouted(chatId: string, messageId?: string) {
return this.database.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
last_routed_message_id = $2,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
`,
[chatId, messageId || null],
);
}
enrichAssignment(assignmentId: number) {
return this.database.query(
`
SELECT
wca.*,
a.nome AS area_nome,
u.nome AS user_nome,
u.email AS user_email
FROM whatsapp_chat_atribuicoes wca
LEFT JOIN areas a ON a.id = wca.area_id
LEFT JOIN usuarios u ON u.id = wca.user_id
WHERE wca.id = $1
`,
[assignmentId],
);
}
}

View File

@ -0,0 +1,157 @@
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
export class WhatsappMediaDto {
@IsString()
@IsNotEmpty()
data: string;
@IsString()
@IsNotEmpty()
@MaxLength(160)
mimetype: string;
@IsOptional()
@IsString()
@MaxLength(260)
filename?: string;
}
export class SendWhatsappMessageDto {
@IsString()
@IsNotEmpty()
to: string;
@IsString()
message: string;
@IsOptional()
@IsString()
@MaxLength(120)
senderName?: string;
@IsOptional()
@ValidateNested()
@Type(() => WhatsappMediaDto)
media?: WhatsappMediaDto;
}
export class StartAttendanceDto {
@IsString()
@IsNotEmpty()
to: string;
@Type(() => Number)
@IsInt()
templateId: number;
@Type(() => Number)
@IsInt()
userId: number;
@IsOptional()
@Type(() => Number)
@IsInt()
areaId?: number | null;
@IsOptional()
@IsObject()
variables?: Record<string, string | null | undefined>;
}
export class AssignChatDto {
@IsString()
@IsNotEmpty()
chatId: string;
@IsString()
@IsNotEmpty()
userId: string;
@IsOptional()
@IsString()
areaId?: string;
}
export class TransferChatDto {
@IsString()
@IsNotEmpty()
chatId: string;
@Type(() => Number)
@IsInt()
areaId: number;
@IsOptional()
@Type(() => Number)
@IsInt()
userId?: number | null;
@IsOptional()
@IsString()
note?: string | null;
}
export class CloseChatDto {
@IsString()
@IsNotEmpty()
chatId: string;
@IsOptional()
userId?: string | number | null;
}
export class CreateWhatsappTemplateDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;
@IsString()
@IsNotEmpty()
content: string;
@IsOptional()
@Type(() => Number)
@IsInt()
areaId?: number | null;
@IsOptional()
@IsString()
@MaxLength(40)
requestedByRole?: string;
@IsOptional()
@IsString()
@MaxLength(40)
category?: string;
}
export class UpdateWhatsappTemplateDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;
@IsString()
@IsNotEmpty()
content: string;
@IsOptional()
@Type(() => Number)
@IsInt()
areaId?: number | null;
@IsOptional()
@IsString()
@MaxLength(40)
category?: string;
}

View File

@ -0,0 +1,132 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../../infra/database/database.service';
@Injectable()
export class WhatsappTemplateRepository {
constructor(private readonly database: DatabaseService) {}
listTemplates() {
return this.database.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
`);
}
getTemplateById(id: number) {
return this.database.query('SELECT * FROM whatsapp_templates WHERE id = $1 LIMIT 1', [id]);
}
saveTemplate(input: {
name: string;
content: string;
category: string;
areaId?: number | null;
status: string;
requestedByRole: string;
adminApprovedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
metaSubmittedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
}) {
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
)
VALUES ($1, $2, $3, $4, $5, $6, ${input.adminApprovedAtSql}, ${input.metaSubmittedAtSql}, NULL, 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
RETURNING *
`,
[input.name, input.content, input.category, input.areaId || null, input.status, input.requestedByRole],
);
}
updateTemplate(id: number, input: { name: string; content: string; category: string; areaId?: number | 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
RETURNING *
`,
[input.name, input.content, input.category, input.areaId || null, id],
);
}
approveTemplateByAdmin(id: number) {
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],
);
}
rejectTemplateByAdmin(id: number) {
return this.database.query(
`
UPDATE whatsapp_templates
SET
status = 'rejected',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *
`,
[id],
);
}
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'
`);
}
}

View File

@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
@Injectable()
export class WhatsappTemplateService {
constructor(private readonly whatsappTemplateRepository: WhatsappTemplateRepository) {}
async getTemplates() {
await this.whatsappTemplateRepository.refreshMetaApprovals();
const result = await this.whatsappTemplateRepository.listTemplates();
return result.rows;
}
async getTemplateById(id: number) {
await this.whatsappTemplateRepository.refreshMetaApprovals();
const result = await this.whatsappTemplateRepository.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',
});
return result.rows[0];
}
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),
});
return result.rows[0];
}
async approveTemplateByAdmin(id: number) {
const result = await this.whatsappTemplateRepository.approveTemplateByAdmin(id);
return result.rows[0];
}
async rejectTemplateByAdmin(id: number) {
const result = await this.whatsappTemplateRepository.rejectTemplateByAdmin(id);
return result.rows[0];
}
async deleteTemplate(id: number) {
await this.whatsappTemplateRepository.deleteTemplate(id);
return { success: true };
}
private normalizeTemplateCategory(category?: string) {
const normalized = String(category || 'UTILITY').trim().toUpperCase();
return ['UTILITY', 'MARKETING', 'AUTHENTICATION'].includes(normalized) ? normalized : 'UTILITY';
}
}

View File

@ -2,13 +2,24 @@ import { Controller, Get, Post, Body, Delete, Param } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { WhatsappService } from './whatsapp.service';
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
import {
AssignChatDto,
CloseChatDto,
CreateWhatsappTemplateDto,
SendWhatsappMessageDto,
StartAttendanceDto,
TransferChatDto,
UpdateWhatsappTemplateDto,
} from './dto/whatsapp.dto';
import { WhatsappTemplateService } from './whatsapp-template.service';
@ApiTags('WhatsApp')
@Controller('whatsapp')
export class WhatsappController {
constructor(
private readonly whatsappService: WhatsappService,
private readonly assignmentService: AttendanceAssignmentService
private readonly assignmentService: AttendanceAssignmentService,
private readonly whatsappTemplateService: WhatsappTemplateService,
) {}
@ApiOperation({
@ -80,7 +91,7 @@ export class WhatsappController {
})
@ApiResponse({ status: 201, description: 'Mensagem enviada.' })
@Post('send')
async sendMessage(@Body() body: { to: string; message: string; senderName?: string; media?: { data: string; mimetype: string; filename?: string } }) {
async sendMessage(@Body() body: SendWhatsappMessageDto) {
return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName);
}
@ -103,7 +114,7 @@ export class WhatsappController {
})
@ApiResponse({ status: 201, description: 'Atendimento ativo iniciado.' })
@Post('start-attendance')
async startAttendance(@Body() body: { to: string; templateId: number; userId: number; areaId?: number | null; variables?: Record<string, string | null | undefined> }) {
async startAttendance(@Body() body: StartAttendanceDto) {
return this.whatsappService.startAttendance(body.to, body.templateId, body.userId, body.areaId, body.variables);
}
@ -123,7 +134,7 @@ export class WhatsappController {
},
})
@Post('assign')
async assignChat(@Body() body: { chatId: string; userId: string; areaId?: string }) {
async assignChat(@Body() body: AssignChatDto) {
return this.assignmentService.assignChat(body.chatId, body.userId, body.areaId);
}
@ -144,7 +155,7 @@ export class WhatsappController {
},
})
@Post('transfer')
async transferChat(@Body() body: { chatId: string; areaId: number; userId?: number | null; note?: string | null }) {
async transferChat(@Body() body: TransferChatDto) {
return this.assignmentService.transferChat(body);
}
@ -173,7 +184,7 @@ export class WhatsappController {
},
})
@Post('close')
async closeChat(@Body() body: { chatId: string; userId?: string | number | null }) {
async closeChat(@Body() body: CloseChatDto) {
return this.assignmentService.closeChat(body.chatId, body.userId);
}
@ -193,7 +204,7 @@ export class WhatsappController {
})
@Get('templates')
async getTemplates() {
return this.whatsappService.getTemplates();
return this.whatsappTemplateService.getTemplates();
}
@ApiOperation({
@ -214,8 +225,8 @@ export class WhatsappController {
},
})
@Post('templates')
async saveTemplate(@Body() body: { name: string; content: string; areaId?: number | null; requestedByRole?: string; category?: string }) {
return this.whatsappService.saveTemplate(body.name, body.content, body.areaId, body.requestedByRole, body.category);
async saveTemplate(@Body() body: CreateWhatsappTemplateDto) {
return this.whatsappTemplateService.saveTemplate(body.name, body.content, body.areaId, body.requestedByRole, body.category);
}
@ApiOperation({
@ -224,8 +235,8 @@ export class WhatsappController {
})
@ApiParam({ name: 'id', description: 'ID do template.' })
@Post('templates/update/:id')
async updateTemplate(@Param('id') id: string, @Body() body: { name: string; content: string; areaId?: number | null; category?: string }) {
return this.whatsappService.updateTemplate(Number(id), body.name, body.content, body.areaId, body.category);
async updateTemplate(@Param('id') id: string, @Body() body: UpdateWhatsappTemplateDto) {
return this.whatsappTemplateService.updateTemplate(Number(id), body.name, body.content, body.areaId, body.category);
}
@ApiOperation({
@ -235,7 +246,7 @@ export class WhatsappController {
@ApiParam({ name: 'id', description: 'ID do template.' })
@Post('templates/approve-admin/:id')
async approveTemplateByAdmin(@Param('id') id: string) {
return this.whatsappService.approveTemplateByAdmin(Number(id));
return this.whatsappTemplateService.approveTemplateByAdmin(Number(id));
}
@ApiOperation({
@ -245,7 +256,7 @@ export class WhatsappController {
@ApiParam({ name: 'id', description: 'ID do template.' })
@Post('templates/reject-admin/:id')
async rejectTemplateByAdmin(@Param('id') id: string) {
return this.whatsappService.rejectTemplateByAdmin(Number(id));
return this.whatsappTemplateService.rejectTemplateByAdmin(Number(id));
}
@ApiOperation({
@ -255,6 +266,6 @@ export class WhatsappController {
@ApiParam({ name: 'id', description: 'ID do template.' })
@Delete('templates/:id')
async deleteTemplate(@Param('id') id: string) {
return this.whatsappService.deleteTemplate(Number(id));
return this.whatsappTemplateService.deleteTemplate(Number(id));
}
}

View File

@ -4,10 +4,12 @@ import { WhatsappGateway } from './whatsapp.gateway';
import { WhatsappController } from './whatsapp.controller';
import { AttendanceModule } from '../attendance/attendance.module';
import { AuthModule } from '../auth/auth.module';
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
import { WhatsappTemplateService } from './whatsapp-template.service';
@Module({
imports: [AttendanceModule, AuthModule],
providers: [WhatsappService, WhatsappGateway],
providers: [WhatsappService, WhatsappGateway, WhatsappTemplateService, WhatsappTemplateRepository],
controllers: [WhatsappController],
exports: [WhatsappService],
})

View File

@ -3,6 +3,7 @@ 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 { WhatsappTemplateService } from './whatsapp-template.service';
import * as fs from 'fs';
import * as path from 'path';
@ -19,51 +20,11 @@ export class WhatsappService implements OnModuleInit {
constructor(
private readonly gateway: WhatsappGateway,
private readonly assignmentService: AttendanceAssignmentService,
private readonly db: DatabaseService
private readonly db: DatabaseService,
private readonly whatsappTemplateService: WhatsappTemplateService,
) {}
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',
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
);
`);
await this.db.query(`
ALTER TABLE whatsapp_templates
ADD COLUMN IF NOT EXISTS category VARCHAR(40) NOT NULL DEFAULT 'UTILITY',
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({
@ -569,7 +530,7 @@ export class WhatsappService implements OnModuleInit {
areaId?: number | null,
variables?: Record<string, string | null | undefined>,
) {
const template = await this.getTemplateById(templateId);
const template = await this.whatsappTemplateService.getTemplateById(templateId);
if (!template) {
throw new Error('Template de WhatsApp nao encontrado');
}
@ -625,137 +586,4 @@ export class WhatsappService implements OnModuleInit {
return `*Atendente: ${cleanSenderName}*\n\n${cleanMessage}`;
}
async getTemplates() {
await this.refreshMetaApprovals();
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') {
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,
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)
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
RETURNING *
`,
[name, content, this.normalizeTemplateCategory(category), areaId || null, status, requestedByRole]
);
return res.rows[0];
}
async updateTemplate(id: number, name: string, content: string, areaId?: number | null, category = 'UTILITY') {
const res = await this.db.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
RETURNING *
`,
[name, content, this.normalizeTemplateCategory(category), areaId || null, id]
);
return res.rows[0];
}
async approveTemplateByAdmin(id: number) {
const res = await this.db.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],
);
return res.rows[0];
}
async rejectTemplateByAdmin(id: number) {
const res = await this.db.query(
`
UPDATE whatsapp_templates
SET
status = 'rejected',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *
`,
[id],
);
return res.rows[0];
}
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() {
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'
`);
}
}