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:
parent
1f5d84e065
commit
6097f730b7
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
157
src/modules/whatsapp/dto/whatsapp.dto.ts
Normal file
157
src/modules/whatsapp/dto/whatsapp.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
66
src/modules/whatsapp/whatsapp-template.service.ts
Normal file
66
src/modules/whatsapp/whatsapp-template.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user