diff --git a/src/modules/attendance/attendance-assignment.service.ts b/src/modules/attendance/attendance-assignment.service.ts index d06e7cc..c509c39 100644 --- a/src/modules/attendance/attendance-assignment.service.ts +++ b/src/modules/attendance/attendance-assignment.service.ts @@ -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 = [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 { - const flows = await this.db.query( - ` - 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( - ` - 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( - ` - 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 { - const versionResult = await this.db.query( - ` - 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( - ` - 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(); 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; } } diff --git a/src/modules/attendance/attendance.module.ts b/src/modules/attendance/attendance.module.ts index 1fb1087..f1447aa 100644 --- a/src/modules/attendance/attendance.module.ts +++ b/src/modules/attendance/attendance.module.ts @@ -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 {} diff --git a/src/modules/attendance/repositories/attendance-assignment.repository.ts b/src/modules/attendance/repositories/attendance-assignment.repository.ts new file mode 100644 index 0000000..7cc746e --- /dev/null +++ b/src/modules/attendance/repositories/attendance-assignment.repository.ts @@ -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 = [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( + ` + SELECT * + FROM bot_triage_flows + WHERE active = TRUE + ORDER BY id ASC + LIMIT 1 + `, + ); + } + + listActiveTriageAudiences(flowId: number) { + return this.database.query( + ` + 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( + ` + 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( + ` + 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( + ` + 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], + ); + } +} diff --git a/src/modules/whatsapp/dto/whatsapp.dto.ts b/src/modules/whatsapp/dto/whatsapp.dto.ts new file mode 100644 index 0000000..a62e79b --- /dev/null +++ b/src/modules/whatsapp/dto/whatsapp.dto.ts @@ -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; +} + +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; +} diff --git a/src/modules/whatsapp/repositories/whatsapp-template.repository.ts b/src/modules/whatsapp/repositories/whatsapp-template.repository.ts new file mode 100644 index 0000000..f323062 --- /dev/null +++ b/src/modules/whatsapp/repositories/whatsapp-template.repository.ts @@ -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' + `); + } +} diff --git a/src/modules/whatsapp/whatsapp-template.service.ts b/src/modules/whatsapp/whatsapp-template.service.ts new file mode 100644 index 0000000..06efacf --- /dev/null +++ b/src/modules/whatsapp/whatsapp-template.service.ts @@ -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'; + } +} diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts index 2160f01..6635732 100644 --- a/src/modules/whatsapp/whatsapp.controller.ts +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -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 }) { + 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)); } } diff --git a/src/modules/whatsapp/whatsapp.module.ts b/src/modules/whatsapp/whatsapp.module.ts index c2010de..1d4c365 100644 --- a/src/modules/whatsapp/whatsapp.module.ts +++ b/src/modules/whatsapp/whatsapp.module.ts @@ -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], }) diff --git a/src/modules/whatsapp/whatsapp.service.ts b/src/modules/whatsapp/whatsapp.service.ts index 5d30e17..7ab6950 100644 --- a/src/modules/whatsapp/whatsapp.service.ts +++ b/src/modules/whatsapp/whatsapp.service.ts @@ -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, ) { - 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' - `); - } }