diff --git a/src/modules/admin/admin-access.controller.ts b/src/modules/admin/admin-access.controller.ts index 852bea1..d408e96 100644 --- a/src/modules/admin/admin-access.controller.ts +++ b/src/modules/admin/admin-access.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { AdminAccessService } from './admin-access.service'; @Controller('admin/access') @@ -15,6 +15,40 @@ export class AdminAccessController { return this.adminAccessService.getOverview(); } + @Get('ranking') + getRanking(@Query('areaId') areaId?: string) { + return this.adminAccessService.getAttendantRanking(areaId ? Number(areaId) : null); + } + + @Get('audit') + listAuditLogs(@Query('page') page?: string, @Query('limit') limit?: string) { + return this.adminAccessService.listAuditLogs(Number(page || 1), Number(limit || 100)); + } + + @Get('ai-contents') + listAiContents() { + return this.adminAccessService.listAiContents(); + } + + @Post('ai-contents') + createAiContent(@Body() body: { + title?: string; + areaId?: number | null; + filename?: string | null; + mimetype?: string | null; + fileSize?: number | null; + contentBase64?: string | null; + notes?: string | null; + createdByUserId?: number | null; + }) { + return this.adminAccessService.createAiContent(body); + } + + @Delete('ai-contents/:id') + deleteAiContent(@Param('id') id: string) { + return this.adminAccessService.deleteAiContent(Number(id)); + } + @Get('areas') listAreas() { return this.adminAccessService.listAreas(); @@ -33,6 +67,11 @@ export class AdminAccessController { return this.adminAccessService.updateArea(Number(id), body); } + @Delete('areas/:id') + deleteArea(@Param('id') id: string) { + return this.adminAccessService.deleteArea(Number(id)); + } + @Get('users') listUsers() { return this.adminAccessService.listUsers(); diff --git a/src/modules/admin/admin-access.service.ts b/src/modules/admin/admin-access.service.ts index aa3834b..cc4fe2a 100644 --- a/src/modules/admin/admin-access.service.ts +++ b/src/modules/admin/admin-access.service.ts @@ -20,10 +20,57 @@ interface AreaInput { ativo?: boolean; } +interface AiContentInput { + title?: string; + areaId?: number | null; + filename?: string | null; + mimetype?: string | null; + fileSize?: number | null; + contentBase64?: string | null; + notes?: string | null; + createdByUserId?: number | null; +} + @Injectable() export class AdminAccessService { constructor(private readonly database: DatabaseService) {} + async onModuleInit() { + await this.ensureAdminSchema(); + } + + async ensureAdminSchema() { + await this.database.query(` + CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id SERIAL PRIMARY KEY, + actor_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL, + actor_name VARCHAR(180), + action VARCHAR(120) NOT NULL, + target_type VARCHAR(80), + target_id VARCHAR(120), + details TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `); + + await this.database.query(` + CREATE TABLE IF NOT EXISTS ai_knowledge_contents ( + id SERIAL PRIMARY KEY, + title VARCHAR(220) NOT NULL, + area_id INTEGER REFERENCES areas(id) ON DELETE SET NULL, + filename VARCHAR(260), + mimetype VARCHAR(160), + file_size INTEGER, + content_base64 TEXT, + status VARCHAR(40) NOT NULL DEFAULT 'available', + notes TEXT, + created_by_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `); + } + async getOptions() { const [profiles, areas] = await Promise.all([ this.database.query<{ id: number; nome: string }>( @@ -127,6 +174,189 @@ export class AdminAccessService { }; } + async getAttendantRanking(areaId?: number | null) { + const params: unknown[] = []; + const areaFilter = areaId ? 'AND wca.area_id = $1' : ''; + if (areaId) params.push(areaId); + + const result = await this.database.query( + ` + SELECT + u.id, + u.nome AS name, + COALESCE(a.nome, 'Sem especialidade') AS area, + COUNT(wca.id)::INTEGER AS closed, + COALESCE(ROUND(AVG(EXTRACT(EPOCH FROM (wca.updated_at - wca.assigned_at))) / 60)::INTEGER, 0) AS avg_minutes + FROM whatsapp_chat_atribuicoes wca + INNER JOIN usuarios u ON u.id = wca.user_id OR u.id = wca.reserved_user_id + LEFT JOIN areas a ON a.id = wca.area_id + WHERE wca.assigned_at IS NOT NULL + AND wca.conversation_started_at >= date_trunc('month', CURRENT_DATE) + AND wca.status IN ('expired', 'assigned') + ${areaFilter} + GROUP BY u.id, u.nome, a.nome + ORDER BY closed DESC, avg_minutes ASC + LIMIT 10 + `, + params, + ); + + return result.rows.map((row: any) => ({ + id: `${row.id}-${row.area}`, + name: row.name, + area: row.area, + closed: Number(row.closed || 0), + avgTime: `${Number(row.avg_minutes || 0)} min`, + satisfaction: 'Sem dados', + })); + } + + async listAuditLogs(page = 1, limit = 100) { + const safePage = Math.max(1, Number(page) || 1); + const safeLimit = Math.min(100, Math.max(1, Number(limit) || 100)); + const offset = (safePage - 1) * safeLimit; + + const result = await this.database.query( + ` + WITH events AS ( + SELECT + ('audit-' || id)::TEXT AS id, + created_at, + COALESCE(actor_name, 'Sistema') AS actor, + action, + COALESCE(target_type, 'Registro') AS target_type, + target_id, + details + FROM admin_audit_logs + + UNION ALL + + SELECT + ('area-' || id)::TEXT AS id, + updated_at AS created_at, + 'Admin' AS actor, + CASE WHEN ativo THEN 'Especialidade atualizada' ELSE 'Especialidade desativada' END AS action, + 'Especialidade' AS target_type, + id::TEXT AS target_id, + nome AS details + FROM areas + + UNION ALL + + SELECT + ('template-' || wt.id)::TEXT AS id, + wt.updated_at AS created_at, + COALESCE(wt.requested_by_role, 'Admin') AS actor, + CASE wt.status + WHEN 'approved' THEN 'Template aprovado' + WHEN 'rejected' THEN 'Template reprovado' + WHEN 'meta_review' THEN 'Template enviado para análise' + ELSE 'Template atualizado' + END AS action, + 'Template' AS target_type, + wt.id::TEXT AS target_id, + wt.name AS details + FROM whatsapp_templates wt + + UNION ALL + + SELECT + ('assignment-' || wca.id)::TEXT AS id, + wca.updated_at AS created_at, + COALESCE(u.nome, 'Sistema') AS actor, + CASE wca.status + WHEN 'queued' THEN 'Atendimento em fila' + WHEN 'assigned' THEN 'Atendimento atribuído' + WHEN 'expired' THEN 'Atendimento encerrado' + ELSE 'Atendimento atualizado' + END AS action, + 'Atendimento' AS target_type, + wca.chat_id AS target_id, + COALESCE(a.nome, wca.transfer_note, '') AS details + FROM whatsapp_chat_atribuicoes wca + LEFT JOIN usuarios u ON u.id = wca.user_id + LEFT JOIN areas a ON a.id = wca.area_id + ), + counted AS ( + SELECT COUNT(*)::INTEGER AS total FROM events + ) + SELECT events.*, counted.total + FROM events, counted + ORDER BY events.created_at DESC, events.id DESC + LIMIT $1 OFFSET $2 + `, + [safeLimit, offset], + ); + + return { + page: safePage, + limit: safeLimit, + total: Number(result.rows[0]?.total || 0), + items: result.rows.map(({ total, ...row }) => row), + }; + } + + async listAiContents() { + const result = await this.database.query( + ` + SELECT + c.id, + c.title, + c.area_id, + a.nome AS area_nome, + c.filename, + c.mimetype, + c.file_size, + c.status, + c.notes, + c.created_at, + c.updated_at + FROM ai_knowledge_contents c + LEFT JOIN areas a ON a.id = c.area_id + ORDER BY c.created_at DESC, c.id DESC + `, + ); + + return result.rows; + } + + async createAiContent(input: AiContentInput) { + const title = String(input.title || '').trim(); + if (!title) { + throw new Error('Titulo do conteudo e obrigatorio'); + } + + const result = await this.database.query( + ` + INSERT INTO ai_knowledge_contents ( + title, area_id, filename, mimetype, file_size, content_base64, + status, notes, created_by_user_id, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, 'available', $7, $8, CURRENT_TIMESTAMP) + RETURNING * + `, + [ + title, + input.areaId || null, + this.normalizeText(input.filename), + this.normalizeText(input.mimetype), + input.fileSize || null, + input.contentBase64 || null, + this.normalizeText(input.notes), + input.createdByUserId || null, + ], + ); + + await this.logAudit('Conteúdo de IA adicionado', 'Conteúdo IA', String(result.rows[0].id), title); + return this.listAiContents(); + } + + async deleteAiContent(id: number) { + await this.database.query('DELETE FROM ai_knowledge_contents WHERE id = $1', [id]); + await this.logAudit('Conteúdo de IA removido', 'Conteúdo IA', String(id), 'Remoção de conteúdo'); + return this.listAiContents(); + } + async listAreas() { const result = await this.database.query( ` @@ -148,6 +378,7 @@ export class AdminAccessService { LEFT JOIN usuarios_areas ua ON ua.area_id = a.id AND ua.ativo = TRUE LEFT JOIN usuarios_areas sua ON sua.area_id = a.id AND sua.ativo = TRUE AND sua.funcao = 'Supervisor' LEFT JOIN usuarios su ON su.id = sua.usuario_id + WHERE a.ativo = TRUE GROUP BY a.id, r.nome ORDER BY a.nome `, @@ -248,6 +479,7 @@ export class AdminAccessService { } }); + await this.logAudit('Acesso de usuário atualizado', 'Usuário', String(usuarioId), 'Perfis e especialidades alterados'); return this.listUsers().then((users) => users.find((user) => user.id === usuarioId)); } @@ -261,6 +493,11 @@ export class AdminAccessService { ` INSERT INTO areas (nome, descricao, responsavel_usuario_id, ativo, created_at, updated_at) VALUES ($1, $2, $3, TRUE, NOW(), NOW()) + ON CONFLICT (nome) DO UPDATE SET + descricao = EXCLUDED.descricao, + responsavel_usuario_id = EXCLUDED.responsavel_usuario_id, + ativo = TRUE, + updated_at = NOW() RETURNING * `, [nome, this.normalizeText(input.descricao), input.responsavelUsuarioId || null], @@ -270,6 +507,7 @@ export class AdminAccessService { await this.ensureAreaSupervisor(input.responsavelUsuarioId, result.rows[0].id); } + await this.logAudit('Especialidade criada', 'Especialidade', String(result.rows[0].id), nome); return this.listAreas(); } @@ -299,9 +537,45 @@ export class AdminAccessService { await this.ensureAreaSupervisor(input.responsavelUsuarioId, areaId); } + await this.logAudit('Especialidade atualizada', 'Especialidade', String(areaId), input.nome || result.rows[0]?.nome || ''); return this.listAreas(); } + async deleteArea(areaId: number) { + await this.database.query( + ` + UPDATE areas + SET ativo = FALSE, + updated_at = NOW() + WHERE id = $1 + `, + [areaId], + ); + + await this.database.query( + ` + UPDATE usuarios_areas + SET ativo = FALSE, + updated_at = NOW() + WHERE area_id = $1 + `, + [areaId], + ); + + await this.logAudit('Especialidade desativada', 'Especialidade', String(areaId), 'Especialidade removida da operação'); + return this.listAreas(); + } + + private async logAudit(action: string, targetType: string, targetId: string, details?: string | null) { + await this.database.query( + ` + INSERT INTO admin_audit_logs (actor_name, action, target_type, target_id, details) + VALUES ('Admin', $1, $2, $3, $4) + `, + [action, targetType, targetId, details || null], + ).catch(() => undefined); + } + private async ensureAreaSupervisor(usuarioId: number, areaId: number) { const supervisorProfile = await this.database.query<{ id: number }>( `SELECT id FROM perfis_acesso WHERE nome = 'Supervisor' LIMIT 1`, diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 4b5a5c2..86f308f 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -7,10 +7,24 @@ import { AgentPresenceController } from './agent-presence.controller'; import { AgentPresenceService } from './agent-presence.service'; import { CustomerContactsController } from './customer-contacts.controller'; import { CustomerContactsService } from './customer-contacts.service'; +import { KnowledgeBaseController } from './knowledge-base.controller'; +import { KnowledgeBaseService } from './knowledge-base.service'; @Module({ - controllers: [AdminAccessController, AgentNotesController, AgentPresenceController, CustomerContactsController], - providers: [AdminAccessService, AgentNotesService, AgentPresenceService, CustomerContactsService], + controllers: [ + AdminAccessController, + AgentNotesController, + AgentPresenceController, + CustomerContactsController, + KnowledgeBaseController, + ], + providers: [ + AdminAccessService, + AgentNotesService, + AgentPresenceService, + CustomerContactsService, + KnowledgeBaseService, + ], exports: [AgentPresenceService], }) export class AdminModule {} diff --git a/src/modules/admin/knowledge-base.controller.ts b/src/modules/admin/knowledge-base.controller.ts new file mode 100644 index 0000000..14f57e8 --- /dev/null +++ b/src/modules/admin/knowledge-base.controller.ts @@ -0,0 +1,132 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { KnowledgeBaseService } from './knowledge-base.service'; + +@Controller('admin/knowledge') +export class KnowledgeBaseController { + constructor(private readonly knowledgeBaseService: KnowledgeBaseService) {} + + @Get('bot-flow') + getBotFlow() { + return this.knowledgeBaseService.getBotFlow(); + } + + @Get('bot-flow/versions') + listBotFlowVersions() { + return this.knowledgeBaseService.listBotFlowVersions(); + } + + @Post('bot-flow/nodes') + createBotFlowNode(@Body() body: { + parentId: number; + nodeType: 'question' | 'agent' | 'close'; + title?: string; + messageText?: string; + keywords?: string; + fallbackMessage?: string; + fallbackAttempts?: number; + fallbackAreaId?: number | null; + areaId?: number | null; + }) { + return this.knowledgeBaseService.createBotFlowNode(body); + } + + @Put('bot-flow/nodes/:id') + updateBotFlowNode(@Param('id') id: string, @Body() body: { + title?: string; + messageText?: string; + keywords?: string; + fallbackMessage?: string; + fallbackAttempts?: number; + fallbackAreaId?: number | null; + areaId?: number | null; + sortOrder?: number; + }) { + return this.knowledgeBaseService.updateBotFlowNode(Number(id), body); + } + + @Delete('bot-flow/nodes/:id') + deleteBotFlowNode(@Param('id') id: string) { + return this.knowledgeBaseService.deleteBotFlowNode(Number(id)); + } + + @Post('bot-flow/publish') + publishBotFlow() { + return this.knowledgeBaseService.publishBotFlow(); + } + + @Get('triage-flow') + getTriageFlow() { + return this.knowledgeBaseService.getTriageFlow(); + } + + @Put('triage-flow') + updateTriageFlow(@Body() body: { + greetingMessage?: string; + audienceQuestion?: string; + intentQuestionTemplate?: string; + resolutionQuestion?: string; + fallbackMessage?: string; + fallbackAreaId?: number | null; + maxAttempts?: number; + }) { + return this.knowledgeBaseService.updateTriageFlow(body); + } + + @Post('triage-flow/audiences') + createAudience(@Body() body: { label: string; keywords?: string; sortOrder?: number }) { + return this.knowledgeBaseService.createAudience(body); + } + + @Put('triage-flow/audiences/:id') + updateAudience(@Param('id') id: string, @Body() body: { label?: string; keywords?: string; sortOrder?: number; active?: boolean }) { + return this.knowledgeBaseService.updateAudience(Number(id), body); + } + + @Post('triage-flow/intents') + createIntent(@Body() body: { + audienceId: number; + label: string; + areaId: number; + keywords?: string; + responseMessage?: string; + resolutionQuestion?: string; + escalationMessage?: string; + sortOrder?: number; + }) { + return this.knowledgeBaseService.createIntent(body); + } + + @Put('triage-flow/intents/:id') + updateIntent(@Param('id') id: string, @Body() body: { + label?: string; + areaId?: number; + keywords?: string; + responseMessage?: string; + resolutionQuestion?: string; + escalationMessage?: string; + sortOrder?: number; + active?: boolean; + }) { + return this.knowledgeBaseService.updateIntent(Number(id), body); + } + + @Get('routing-keywords') + listRoutingKeywords(@Query('areaId') areaId?: string) { + return this.knowledgeBaseService.listRoutingKeywords(areaId ? Number(areaId) : null); + } + + @Post('routing-keywords') + createRoutingKeyword(@Body() body: { areaId: number; keyword: string }) { + return this.knowledgeBaseService.createRoutingKeyword(body); + } + + @Put('routing-keywords/:id') + updateRoutingKeyword(@Param('id') id: string, @Body() body: { keyword?: string; active?: boolean }) { + return this.knowledgeBaseService.updateRoutingKeyword(Number(id), body); + } + + @Delete('routing-keywords/:id') + deleteRoutingKeyword(@Param('id') id: string) { + return this.knowledgeBaseService.deleteRoutingKeyword(Number(id)); + } +} diff --git a/src/modules/admin/knowledge-base.service.ts b/src/modules/admin/knowledge-base.service.ts new file mode 100644 index 0000000..387e0a6 --- /dev/null +++ b/src/modules/admin/knowledge-base.service.ts @@ -0,0 +1,878 @@ +import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common'; +import { DatabaseService } from '../../infra/database/database.service'; + +@Injectable() +export class KnowledgeBaseService implements OnModuleInit { + constructor(private readonly database: DatabaseService) {} + + async onModuleInit() { + await this.ensureSchema(); + } + + async ensureSchema() { + await this.database.query(` + CREATE TABLE IF NOT EXISTS area_routing_keywords ( + id SERIAL PRIMARY KEY, + area_id INTEGER NOT NULL REFERENCES areas(id) ON DELETE CASCADE, + keyword VARCHAR(160) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_area_routing_keyword UNIQUE (area_id, keyword) + ); + `); + + await this.database.query(` + ALTER TABLE bot_triage_flows + ADD COLUMN IF NOT EXISTS resolution_question TEXT NOT NULL DEFAULT 'Essa informacao resolveu sua duvida? Responda 1 para encerrar ou 2 para falar com um especialista.'; + `).catch(() => undefined); + + await this.database.query(` + ALTER TABLE bot_triage_intents + ADD COLUMN IF NOT EXISTS response_message TEXT, + ADD COLUMN IF NOT EXISTS resolution_question TEXT, + ADD COLUMN IF NOT EXISTS escalation_message TEXT NOT NULL DEFAULT 'Certo, vou encaminhar seu atendimento para um especialista no assunto.'; + `).catch(() => undefined); + + await this.database.query(` + CREATE TABLE IF NOT EXISTS bot_flow_versions ( + id SERIAL PRIMARY KEY, + name VARCHAR(160) NOT NULL DEFAULT 'Fluxo RH Sothis', + status VARCHAR(30) NOT NULL DEFAULT 'draft', + version_number INTEGER NOT NULL DEFAULT 0, + root_node_id INTEGER, + published_at TIMESTAMP WITH TIME ZONE, + snapshot JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `); + + await this.database.query(` + CREATE TABLE IF NOT EXISTS bot_flow_nodes ( + id SERIAL PRIMARY KEY, + version_id INTEGER NOT NULL REFERENCES bot_flow_versions(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES bot_flow_nodes(id) ON DELETE CASCADE, + node_type VARCHAR(30) NOT NULL CHECK (node_type IN ('greeting', 'question', 'agent', 'close')), + title VARCHAR(160) NOT NULL, + message_text TEXT, + keywords TEXT, + fallback_message TEXT, + fallback_attempts INTEGER NOT NULL DEFAULT 2, + fallback_area_id INTEGER REFERENCES areas(id) ON DELETE SET NULL, + area_id INTEGER REFERENCES areas(id) ON DELETE SET NULL, + sort_order INTEGER NOT NULL DEFAULT 1, + position_x INTEGER NOT NULL DEFAULT 0, + position_y INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `); + + await this.database.query(` + ALTER TABLE bot_flow_nodes + DROP CONSTRAINT IF EXISTS bot_flow_nodes_node_type_check; + ALTER TABLE bot_flow_nodes + ADD CONSTRAINT bot_flow_nodes_node_type_check + CHECK (node_type IN ('greeting', 'question', 'agent', 'close')); + `).catch(() => undefined); + + await this.database.query(` + ALTER TABLE whatsapp_chat_atribuicoes + ADD COLUMN IF NOT EXISTS triage_builder_version_id INTEGER, + ADD COLUMN IF NOT EXISTS triage_builder_node_id INTEGER; + `).catch(() => undefined); + + await this.ensureDraftBotFlow(); + } + + async getBotFlow() { + const draft = await this.ensureDraftBotFlow(); + const nodes = await this.getFlowNodes(draft.id); + const latestPublished = await this.database.query( + ` + SELECT id, version_number, published_at + FROM bot_flow_versions + WHERE status = 'published' + ORDER BY published_at DESC, id DESC + LIMIT 1 + `, + ); + + return { + ...draft, + nodes, + tree: this.buildNodeTree(nodes, draft.root_node_id), + latestPublished: latestPublished.rows[0] || null, + }; + } + + async listBotFlowVersions() { + const result = await this.database.query( + ` + SELECT id, name, status, version_number, published_at, created_at + FROM bot_flow_versions + WHERE status = 'published' + ORDER BY published_at DESC, id DESC + `, + ); + + return result.rows; + } + + async createBotFlowNode(input: { + parentId: number; + nodeType: 'question' | 'agent' | 'close'; + title?: string; + messageText?: string; + keywords?: string; + fallbackMessage?: string; + fallbackAttempts?: number; + fallbackAreaId?: number | null; + areaId?: number | null; + }) { + const draft = await this.ensureDraftBotFlow(); + const parentId = Number(input.parentId); + const parent = await this.getDraftNode(parentId, draft.id); + if (!parent) throw new BadRequestException('No pai nao encontrado no fluxo.'); + if (parent.node_type === 'agent') throw new BadRequestException('Um no de envio para agente nao pode ter filhos.'); + + const nodeType = input.nodeType; + if (!['question', 'agent', 'close'].includes(nodeType)) { + throw new BadRequestException('Tipo de no invalido.'); + } + + if (nodeType === 'agent' && !input.areaId) { + throw new BadRequestException('Selecione a especialidade de destino.'); + } + + if (nodeType === 'question' && !this.cleanOptional(input.messageText)) { + throw new BadRequestException('Informe a mensagem da pergunta.'); + } + + await this.assertNoKeywordConflict(parentId, this.cleanOptional(input.keywords), null); + const count = await this.database.query<{ total: string }>( + 'SELECT COUNT(*)::TEXT AS total FROM bot_flow_nodes WHERE parent_id = $1', + [parentId], + ); + + const title = + this.cleanOptional(input.title) || + (nodeType === 'agent' ? 'Enviar para agente' : nodeType === 'close' ? 'Encerrar pelo bot' : 'Pergunta'); + + const result = await this.database.query( + ` + INSERT INTO bot_flow_nodes ( + version_id, parent_id, node_type, title, message_text, keywords, + fallback_message, fallback_attempts, fallback_area_id, area_id, + sort_order, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, 2), $9, $10, $11, CURRENT_TIMESTAMP) + RETURNING * + `, + [ + draft.id, + parentId, + nodeType, + title, + this.cleanOptional(input.messageText), + this.cleanOptional(input.keywords), + this.cleanOptional(input.fallbackMessage), + input.fallbackAttempts ? Number(input.fallbackAttempts) : null, + input.fallbackAreaId || null, + input.areaId || null, + Number(count.rows[0]?.total || 0) + 1, + ], + ); + + return result.rows[0]; + } + + async updateBotFlowNode(id: number, input: { + title?: string; + messageText?: string; + keywords?: string; + fallbackMessage?: string; + fallbackAttempts?: number; + fallbackAreaId?: number | null; + areaId?: number | null; + sortOrder?: number; + }) { + const draft = await this.ensureDraftBotFlow(); + const node = await this.getDraftNode(Number(id), draft.id); + if (!node) throw new BadRequestException('No nao encontrado no fluxo.'); + + if (node.node_type === 'greeting' && input.keywords) { + throw new BadRequestException('O no raiz nao usa keywords.'); + } + + if (node.node_type !== 'greeting') { + await this.assertNoKeywordConflict(Number(node.parent_id), this.cleanOptional(input.keywords), Number(id)); + } + + const result = await this.database.query( + ` + UPDATE bot_flow_nodes + SET + title = COALESCE($2, title), + message_text = COALESCE($3, message_text), + keywords = COALESCE($4, keywords), + fallback_message = COALESCE($5, fallback_message), + fallback_attempts = COALESCE($6, fallback_attempts), + fallback_area_id = $7, + area_id = $8, + sort_order = COALESCE($9, sort_order), + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + AND version_id = $10 + RETURNING * + `, + [ + id, + this.cleanOptional(input.title), + this.cleanOptional(input.messageText), + this.cleanOptional(input.keywords), + this.cleanOptional(input.fallbackMessage), + input.fallbackAttempts ? Number(input.fallbackAttempts) : null, + input.fallbackAreaId || null, + input.areaId || null, + input.sortOrder ? Number(input.sortOrder) : null, + draft.id, + ], + ); + + return result.rows[0]; + } + + async deleteBotFlowNode(id: number) { + const draft = await this.ensureDraftBotFlow(); + const node = await this.getDraftNode(Number(id), draft.id); + if (!node) throw new BadRequestException('No nao encontrado no fluxo.'); + if (node.node_type === 'greeting') throw new BadRequestException('O no raiz nao pode ser removido.'); + + await this.database.query('DELETE FROM bot_flow_nodes WHERE id = $1 AND version_id = $2', [id, draft.id]); + return { success: true }; + } + + async publishBotFlow() { + const draft = await this.ensureDraftBotFlow(); + const nodes = await this.getFlowNodes(draft.id); + this.validateFlow(nodes, draft.root_node_id); + + const nextVersion = await this.database.query<{ next_version: number }>( + ` + SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version + FROM bot_flow_versions + WHERE status = 'published' + `, + ); + + const published = await this.database.query( + ` + INSERT INTO bot_flow_versions (name, status, version_number, snapshot, published_at, updated_at) + VALUES ($1, 'published', $2, $3::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING * + `, + [draft.name, nextVersion.rows[0].next_version, JSON.stringify({ nodes })], + ); + + const publishedVersion = published.rows[0]; + const idMap = new Map(); + const childrenByParent = new Map(); + nodes.forEach((node) => { + if (!node.parent_id) return; + const parentId = Number(node.parent_id); + childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), node]); + }); + + const rootNode = nodes.find((node) => Number(node.id) === Number(draft.root_node_id)); + if (!rootNode) { + throw new BadRequestException('O fluxo precisa ter um no raiz de saudacao.'); + } + + const copyNode = async (node: any, parentId: number | null): Promise => { + const copied = await this.database.query( + ` + INSERT INTO bot_flow_nodes ( + version_id, parent_id, node_type, title, message_text, keywords, + fallback_message, fallback_attempts, fallback_area_id, area_id, + sort_order, position_x, position_y, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, CURRENT_TIMESTAMP) + RETURNING * + `, + [ + publishedVersion.id, + parentId, + node.node_type, + node.title, + node.message_text, + node.keywords, + node.fallback_message, + node.fallback_attempts, + node.fallback_area_id, + node.area_id, + node.sort_order, + node.position_x, + node.position_y, + ], + ); + const copiedId = Number(copied.rows[0].id); + idMap.set(Number(node.id), copiedId); + + const children = childrenByParent.get(Number(node.id)) || []; + for (const child of children) { + await copyNode(child, copiedId); + } + }; + + await copyNode(rootNode, null); + + const rootNodeId = idMap.get(Number(draft.root_node_id)); + await this.database.query( + 'UPDATE bot_flow_versions SET root_node_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1', + [publishedVersion.id, rootNodeId || null], + ); + + return this.getBotFlow(); + } + + async quickPublishBotFlow() { + const draft = await this.ensureDraftBotFlow(); + const root = await this.getDraftNode(Number(draft.root_node_id), draft.id); + if (!root) throw new BadRequestException('O fluxo precisa ter um no raiz de saudacao.'); + + const supportArea = await this.database.query<{ id: number }>( + `SELECT id FROM areas WHERE nome = 'Suporte' ORDER BY id ASC LIMIT 1`, + ); + const fallbackAreaId = supportArea.rows[0]?.id; + if (!fallbackAreaId) throw new BadRequestException('Cadastre a especialidade Suporte antes de publicar um fluxo simples.'); + + const childCount = await this.database.query<{ total: string }>( + 'SELECT COUNT(*)::TEXT AS total FROM bot_flow_nodes WHERE parent_id = $1', + [root.id], + ); + + if (Number(childCount.rows[0]?.total || 0) === 0) { + await this.createBotFlowNode({ + parentId: root.id, + nodeType: 'agent', + title: 'Fallback para atendimento humano', + keywords: '1, atendimento, humano, ajuda, rh', + areaId: fallbackAreaId, + }); + } + + return this.publishBotFlow(); + } + + async getTriageFlow() { + const flowResult = await this.database.query(` + SELECT * + FROM bot_triage_flows + WHERE active = TRUE + ORDER BY id ASC + LIMIT 1 + `); + + const flow = flowResult.rows[0]; + if (!flow) return null; + + const audienceResult = await this.database.query( + ` + SELECT * + FROM bot_triage_audiences + WHERE flow_id = $1 + ORDER BY sort_order ASC, id ASC + `, + [flow.id], + ); + + const audienceIds = audienceResult.rows.map((audience) => audience.id); + const intentResult = audienceIds.length + ? await 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[]) + ORDER BY bti.sort_order ASC, bti.id ASC + `, + [audienceIds], + ) + : { rows: [] }; + + return { + ...flow, + audiences: audienceResult.rows.map((audience) => ({ + ...audience, + intents: intentResult.rows.filter((intent) => Number(intent.audience_id) === Number(audience.id)), + })), + }; + } + + async updateTriageFlow(input: { + greetingMessage?: string; + audienceQuestion?: string; + intentQuestionTemplate?: string; + resolutionQuestion?: string; + fallbackMessage?: string; + fallbackAreaId?: number | null; + maxAttempts?: number; + }) { + const flow = await this.getTriageFlow() as any; + if (!flow) { + throw new BadRequestException('Fluxo de triagem nao encontrado.'); + } + + const result = await this.database.query( + ` + UPDATE bot_triage_flows + SET + greeting_message = COALESCE($2, greeting_message), + audience_question = COALESCE($3, audience_question), + intent_question_template = COALESCE($4, intent_question_template), + resolution_question = COALESCE($5, resolution_question), + fallback_message = COALESCE($6, fallback_message), + fallback_area_id = $7, + max_attempts = COALESCE($8, max_attempts), + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING * + `, + [ + flow.id, + this.cleanOptional(input.greetingMessage), + this.cleanOptional(input.audienceQuestion), + this.cleanOptional(input.intentQuestionTemplate), + this.cleanOptional(input.resolutionQuestion), + this.cleanOptional(input.fallbackMessage), + input.fallbackAreaId || null, + input.maxAttempts ? Number(input.maxAttempts) : null, + ], + ); + + return result.rows[0]; + } + + async createAudience(input: { label: string; keywords?: string; sortOrder?: number }) { + const flow = await this.getTriageFlow() as any; + if (!flow) throw new BadRequestException('Fluxo de triagem nao encontrado.'); + const label = String(input.label || '').trim(); + if (!label) throw new BadRequestException('Informe o nome do publico.'); + + const result = await this.database.query( + ` + INSERT INTO bot_triage_audiences (flow_id, label, keywords, sort_order, active, updated_at) + VALUES ($1, $2, $3, $4, TRUE, CURRENT_TIMESTAMP) + RETURNING * + `, + [flow.id, label, this.cleanOptional(input.keywords), input.sortOrder || flow.audiences.length + 1], + ); + + return result.rows[0]; + } + + async updateAudience(id: number, input: { label?: string; keywords?: string; sortOrder?: number; active?: boolean }) { + const result = await this.database.query( + ` + UPDATE bot_triage_audiences + SET + label = COALESCE($2, label), + keywords = COALESCE($3, keywords), + sort_order = COALESCE($4, sort_order), + active = COALESCE($5, active), + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING * + `, + [ + id, + this.cleanOptional(input.label), + this.cleanOptional(input.keywords), + input.sortOrder ? Number(input.sortOrder) : null, + typeof input.active === 'boolean' ? input.active : null, + ], + ); + + return result.rows[0] || null; + } + + async createIntent(input: { + audienceId: number; + label: string; + areaId: number; + keywords?: string; + responseMessage?: string; + resolutionQuestion?: string; + escalationMessage?: string; + sortOrder?: number; + }) { + const audienceId = Number(input.audienceId); + const areaId = Number(input.areaId); + const label = String(input.label || '').trim(); + + if (!audienceId || !areaId || !label) { + throw new BadRequestException('Informe publico, especialidade e nome da intencao.'); + } + + const count = await this.database.query<{ total: string }>( + 'SELECT COUNT(*)::TEXT AS total FROM bot_triage_intents WHERE audience_id = $1', + [audienceId], + ); + + const result = await this.database.query( + ` + INSERT INTO bot_triage_intents ( + audience_id, label, area_id, keywords, response_message, resolution_question, + escalation_message, sort_order, active, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 'Certo, vou encaminhar seu atendimento para um especialista no assunto.'), $8, TRUE, CURRENT_TIMESTAMP) + RETURNING * + `, + [ + audienceId, + label, + areaId, + this.cleanOptional(input.keywords), + this.cleanOptional(input.responseMessage), + this.cleanOptional(input.resolutionQuestion), + this.cleanOptional(input.escalationMessage), + input.sortOrder || Number(count.rows[0]?.total || 0) + 1, + ], + ); + + return result.rows[0]; + } + + async updateIntent(id: number, input: { + label?: string; + areaId?: number; + keywords?: string; + responseMessage?: string; + resolutionQuestion?: string; + escalationMessage?: string; + sortOrder?: number; + active?: boolean; + }) { + const result = await this.database.query( + ` + UPDATE bot_triage_intents + SET + label = COALESCE($2, label), + area_id = COALESCE($3, area_id), + keywords = COALESCE($4, keywords), + response_message = COALESCE($5, response_message), + resolution_question = COALESCE($6, resolution_question), + escalation_message = COALESCE($7, escalation_message), + sort_order = COALESCE($8, sort_order), + active = COALESCE($9, active), + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING * + `, + [ + id, + this.cleanOptional(input.label), + input.areaId ? Number(input.areaId) : null, + this.cleanOptional(input.keywords), + this.cleanOptional(input.responseMessage), + this.cleanOptional(input.resolutionQuestion), + this.cleanOptional(input.escalationMessage), + input.sortOrder ? Number(input.sortOrder) : null, + typeof input.active === 'boolean' ? input.active : null, + ], + ); + + return result.rows[0] || null; + } + + private async ensureDraftBotFlow() { + const existing = await this.database.query( + ` + SELECT * + FROM bot_flow_versions + WHERE status = 'draft' + ORDER BY id ASC + LIMIT 1 + `, + ).catch(() => ({ rows: [] })); + + let draft = existing.rows[0]; + if (!draft) { + const inserted = await this.database.query( + ` + INSERT INTO bot_flow_versions (name, status, version_number, updated_at) + VALUES ('Fluxo RH Sothis', 'draft', 0, CURRENT_TIMESTAMP) + RETURNING * + `, + ); + draft = inserted.rows[0]; + } + + const root = await this.database.query( + ` + SELECT * + FROM bot_flow_nodes + WHERE version_id = $1 + AND node_type = 'greeting' + ORDER BY id ASC + LIMIT 1 + `, + [draft.id], + ); + + if (!root.rows[0]) { + const insertedRoot = await this.database.query( + ` + INSERT INTO bot_flow_nodes ( + version_id, parent_id, node_type, title, message_text, fallback_message, + fallback_attempts, sort_order, updated_at + ) + VALUES ( + $1, + NULL, + 'greeting', + 'Saudacao inicial', + 'Ola! Sou o Agente Virtual Sothis. Vou te direcionar para o atendimento correto de RH. Digite o numero da opcao que melhor descreve voce:\n\n1 - Sou colaborador ativo\n2 - Sou ex-colaborador\n3 - Sou candidato a uma vaga', + 'Nao consegui identificar seu perfil. Digite 1 para colaborador ativo, 2 para ex-colaborador ou 3 para candidato.', + 2, + 1, + CURRENT_TIMESTAMP + ) + RETURNING * + `, + [draft.id], + ); + + await this.database.query( + 'UPDATE bot_flow_versions SET root_node_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *', + [draft.id, insertedRoot.rows[0].id], + ); + draft.root_node_id = insertedRoot.rows[0].id; + } else if (!draft.root_node_id) { + await this.database.query( + 'UPDATE bot_flow_versions SET root_node_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1', + [draft.id, root.rows[0].id], + ); + draft.root_node_id = root.rows[0].id; + } + + return draft; + } + + private async getFlowNodes(versionId: number) { + const result = await 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], + ); + + return result.rows; + } + + private buildNodeTree(nodes: any[], rootNodeId?: number | null) { + const byId = new Map(); + nodes.forEach((node) => byId.set(Number(node.id), { ...node, children: [] })); + + byId.forEach((node) => { + if (node.parent_id && byId.has(Number(node.parent_id))) { + byId.get(Number(node.parent_id)).children.push(node); + } + }); + + const root = rootNodeId ? byId.get(Number(rootNodeId)) : null; + return root || nodes.find((node) => node.node_type === 'greeting') || null; + } + + private async getDraftNode(id: number, versionId: number) { + const result = await this.database.query( + 'SELECT * FROM bot_flow_nodes WHERE id = $1 AND version_id = $2 LIMIT 1', + [id, versionId], + ); + + return result.rows[0] || null; + } + + private validateFlow(nodes: any[], rootNodeId?: number | null) { + const root = nodes.find((node) => Number(node.id) === Number(rootNodeId)); + if (!root || root.node_type !== 'greeting') { + throw new BadRequestException('O fluxo precisa ter um no raiz de saudacao.'); + } + + const childrenByParent = new Map(); + nodes.forEach((node) => { + if (!node.parent_id) return; + const parentId = Number(node.parent_id); + childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), node]); + }); + + nodes.forEach((node) => { + const children = childrenByParent.get(Number(node.id)) || []; + if (['agent', 'close'].includes(node.node_type) && children.length) { + throw new BadRequestException(`O no "${node.title}" e terminal e nao pode ter filhos.`); + } + + if (!['agent', 'close'].includes(node.node_type) && !children.length) { + throw new BadRequestException(`O no "${node.title}" precisa ter ao menos um filho antes de publicar.`); + } + + if (node.node_type === 'agent' && !node.area_id) { + throw new BadRequestException(`O no "${node.title}" precisa de uma especialidade.`); + } + + if (node.parent_id && !this.parseKeywords(node.keywords).length) { + throw new BadRequestException(`O no "${node.title}" precisa de pelo menos uma keyword.`); + } + + this.assertSiblingKeywordsAreUnique(children); + }); + } + + private assertSiblingKeywordsAreUnique(nodes: any[]) { + const seen = new Map(); + nodes.forEach((node) => { + const ownKeywords = new Set(this.parseKeywords(node.keywords)); + ownKeywords.forEach((keyword) => { + const previous = seen.get(keyword); + if (previous) { + throw new BadRequestException(`Keyword "${keyword}" duplicada entre "${previous}" e "${node.title}".`); + } + seen.set(keyword, node.title); + }); + }); + } + + private async assertNoKeywordConflict(parentId: number, keywords: string | null, ignoreNodeId: number | null) { + const incoming = Array.from(new Set(this.parseKeywords(keywords))); + if (!incoming.length) return; + + const siblings = await this.database.query( + ` + SELECT id, title, keywords + FROM bot_flow_nodes + WHERE parent_id = $1 + AND ($2::int IS NULL OR id <> $2) + `, + [parentId, ignoreNodeId], + ); + + const used = new Map(); + siblings.rows.forEach((node) => { + this.parseKeywords(node.keywords).forEach((keyword) => used.set(keyword, node.title)); + }); + + const conflict = incoming.find((keyword) => used.has(keyword)); + if (conflict) { + throw new BadRequestException(`Keyword "${conflict}" ja esta em uso no no "${used.get(conflict)}".`); + } + } + + private parseKeywords(value?: string | null) { + return String(value || '') + .split(',') + .map((keyword) => this.normalize(keyword).trim()) + .filter(Boolean); + } + + private normalize(value: string) { + return String(value || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase(); + } + + private cleanOptional(value?: string | null) { + const text = String(value || '').trim(); + return text || null; + } + + async listRoutingKeywords(areaId?: number | null) { + const params: unknown[] = []; + const where = areaId ? 'WHERE ark.area_id = $1' : ''; + if (areaId) params.push(areaId); + + const result = await this.database.query( + ` + SELECT + ark.id, + ark.area_id, + a.nome AS area_nome, + ark.keyword, + ark.active, + ark.created_at, + ark.updated_at + FROM area_routing_keywords ark + INNER JOIN areas a ON a.id = ark.area_id + ${where} + ORDER BY a.nome ASC, ark.keyword ASC + `, + params, + ); + + return result.rows; + } + + async createRoutingKeyword(input: { areaId: number; keyword: string }) { + const areaId = Number(input.areaId); + const keyword = String(input.keyword || '').trim().toLowerCase(); + + if (!Number.isFinite(areaId) || areaId <= 0) { + throw new BadRequestException('Especialidade invalida para palavra-chave.'); + } + + if (!keyword) { + throw new BadRequestException('Informe uma palavra-chave.'); + } + + const result = await this.database.query( + ` + INSERT INTO area_routing_keywords (area_id, keyword, active, created_at, updated_at) + VALUES ($1, $2, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (area_id, keyword) DO UPDATE SET + active = TRUE, + updated_at = CURRENT_TIMESTAMP + RETURNING * + `, + [areaId, keyword], + ); + + return result.rows[0]; + } + + async updateRoutingKeyword(id: number, input: { keyword?: string; active?: boolean }) { + const keyword = typeof input.keyword === 'string' ? input.keyword.trim().toLowerCase() : null; + const active = typeof input.active === 'boolean' ? input.active : null; + + const result = await this.database.query( + ` + UPDATE area_routing_keywords + SET + keyword = COALESCE($2, keyword), + active = COALESCE($3, active), + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING * + `, + [id, keyword || null, active], + ); + + return result.rows[0] || null; + } + + async deleteRoutingKeyword(id: number) { + await this.database.query('DELETE FROM area_routing_keywords WHERE id = $1', [id]); + return { success: true }; + } +} diff --git a/src/modules/whatsapp/whatsapp-assignment.service.ts b/src/modules/whatsapp/whatsapp-assignment.service.ts index d78fdac..370929f 100644 --- a/src/modules/whatsapp/whatsapp-assignment.service.ts +++ b/src/modules/whatsapp/whatsapp-assignment.service.ts @@ -9,6 +9,72 @@ interface TransferInput { note?: string | null; } +interface BotMessageVariables { + nome?: string | null; + telefone?: string | null; +} + +interface TriageIntent { + id: number; + label: string; + area_id: number; + area_nome: string; + keywords: string; + response_message?: string; + resolution_question?: string; + escalation_message?: string; + sort_order: number; +} + +interface TriageAudience { + id: number; + label: string; + keywords: string; + sort_order: number; + intents: TriageIntent[]; +} + +interface TriageFlow { + id: number; + name: string; + greeting_message: string; + audience_question: string; + intent_question_template: string; + resolution_question?: string; + fallback_message: string; + fallback_area_id: number | null; + max_attempts: number; + audiences: TriageAudience[]; +} + +interface BotFlowNode { + id: number; + version_id: number; + parent_id: number | null; + node_type: 'greeting' | 'question' | 'agent' | 'close'; + title: string; + message_text?: string | null; + keywords?: string | null; + fallback_message?: string | null; + fallback_attempts: number; + fallback_area_id?: number | null; + fallback_area_nome?: string | null; + area_id?: number | null; + area_nome?: string | null; + sort_order: number; + children: BotFlowNode[]; +} + +interface BotFlowVersion { + id: number; + root_node_id: number; + version_number: number; + nodes: BotFlowNode[]; + root: BotFlowNode; +} + +const VIRTUAL_AGENT_NAME = 'Agente Virtual Sothis'; + const SUPPORT_KEYWORDS = [ 'suporte', 'bug', @@ -82,6 +148,12 @@ export class WhatsappAssignmentService implements OnModuleInit { 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; `); } @@ -103,6 +175,12 @@ export class WhatsappAssignmentService implements OnModuleInit { 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 *; @@ -134,6 +212,12 @@ export class WhatsappAssignmentService implements OnModuleInit { 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 *; `; @@ -161,6 +245,12 @@ export class WhatsappAssignmentService implements OnModuleInit { 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 *; @@ -187,6 +277,10 @@ export class WhatsappAssignmentService implements OnModuleInit { if (!assignment) return null; + if (assignment.status === 'expired' || assignment.status === 'resolved_by_bot') { + return null; + } + 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`, @@ -208,6 +302,12 @@ export class WhatsappAssignmentService implements OnModuleInit { 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 *; @@ -216,6 +316,42 @@ export class WhatsappAssignmentService implements OnModuleInit { 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, + ); + + return result.rows[0] || null; + } + async clearTransferNote(chatId: string) { const result = await this.db.query( ` @@ -263,7 +399,7 @@ export class WhatsappAssignmentService implements OnModuleInit { return !assignment?.awaiting_customer_reply; } - async routeIncomingMessage(chatId: string, message: string, messageId?: string) { + async routeIncomingMessage(chatId: string, message: string, messageId?: string, variables: BotMessageVariables = {}) { const cleanMessage = (message || '').trim(); if (!cleanMessage) { const current = await this.getAssignment(chatId); @@ -280,10 +416,20 @@ export class WhatsappAssignmentService implements OnModuleInit { return { assignment: current, shouldSendBotMessage: false, botMessage: null }; } + const builderFlowResult = await this.routeBuilderFlow(chatId, cleanMessage, current, messageId, variables); + if (builderFlowResult) { + return builderFlowResult; + } + + const configuredFlowResult = await this.routeConfiguredFlow(chatId, cleanMessage, current, messageId); + if (configuredFlowResult) { + return configuredFlowResult; + } + const detectedArea = await this.detectKnownArea(cleanMessage); if (detectedArea) { - const assignment = await this.queueChat(chatId, detectedArea.id, 'Roteado automaticamente pelo Omnino'); + const assignment = await this.queueChat(chatId, detectedArea.id, `Roteado automaticamente pelo ${VIRTUAL_AGENT_NAME}`); await this.markBotRoute(chatId, messageId); const hasPreviousTriage = current?.status === 'bot_triage'; const botMessage = hasPreviousTriage @@ -293,7 +439,7 @@ export class WhatsappAssignmentService implements OnModuleInit { return { assignment, shouldSendBotMessage: true, - botMessage: this.formatSenderMessage('Atendente virtual', 'Omnino', botMessage), + botMessage: this.formatSenderMessage('Atendente virtual', VIRTUAL_AGENT_NAME, botMessage), }; } @@ -315,7 +461,7 @@ export class WhatsappAssignmentService implements OnModuleInit { shouldSendBotMessage: true, botMessage: this.formatSenderMessage( 'Atendente virtual', - 'Omnino', + VIRTUAL_AGENT_NAME, 'Nao consegui identificar a area ideal com seguranca. Vou te encaminhar para o suporte para agilizar seu atendimento.', ), }; @@ -328,20 +474,20 @@ export class WhatsappAssignmentService implements OnModuleInit { shouldSendBotMessage: true, botMessage: this.formatSenderMessage( 'Atendente virtual', - 'Omnino', - 'Para eu te encaminhar corretamente, responda por favor com uma destas opcoes: suporte, financeiro ou comercial.', + VIRTUAL_AGENT_NAME, + 'Para eu te encaminhar corretamente, responda por favor com uma destas opcoes: beneficios, ponto, holerite, ferias, recrutamento ou suporte.', ), }; } const assignment = await this.upsertTriage(chatId, 1, messageId); const intentMessage = - 'Ola, tudo bem? Me chamo Omnino e irei te transferir para alguem especializado. Gostaria de falar com suporte para resolver algum bug ou problema, financeiro para faturas, ou comercial para contratar um novo produto?'; + `Ola, tudo bem? Sou o ${VIRTUAL_AGENT_NAME} e vou te direcionar para o time certo de RH. Voce quer falar sobre beneficios, ponto, holerite, ferias, recrutamento ou suporte?`; return { assignment, shouldSendBotMessage: true, - botMessage: this.formatSenderMessage('Atendente virtual', 'Omnino', intentMessage), + botMessage: this.formatSenderMessage('Atendente virtual', VIRTUAL_AGENT_NAME, intentMessage), }; } @@ -357,8 +503,537 @@ export class WhatsappAssignmentService implements OnModuleInit { return result.rows; } + private async routeBuilderFlow( + chatId: string, + message: string, + current: any, + messageId?: string, + variables: BotMessageVariables = {}, + ) { + const flow = await this.getPublishedBotFlow(); + if (!flow) return null; + + if (!current || current.status !== 'bot_triage' || !current.triage_builder_node_id) { + const assignment = await this.upsertBuilderTriage(chatId, flow.id, flow.root.id, 0, messageId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + this.applyBotVariables(flow.root.message_text || 'Ola! Como posso ajudar?', variables), + ), + }; + } + + if (Number(current.triage_builder_version_id) !== Number(flow.id)) { + const assignment = await this.upsertBuilderTriage(chatId, flow.id, flow.root.id, 0, messageId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + this.applyBotVariables(flow.root.message_text || 'Ola! Como posso ajudar?', variables), + ), + }; + } + + const currentNode = flow.nodes.find((node) => Number(node.id) === Number(current.triage_builder_node_id)); + if (!currentNode) { + const assignment = await this.upsertBuilderTriage(chatId, flow.id, flow.root.id, 0, messageId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + this.applyBotVariables(flow.root.message_text || 'Ola! Como posso ajudar?', variables), + ), + }; + } + + 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], + ); + + return { + assignment: null, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + this.applyBotVariables( + matchedChild.message_text || 'Perfeito, vou encerrar por aqui. Se precisar de algo mais, e so chamar novamente.', + variables, + ), + ), + }; + } + + if (matchedChild.node_type === 'agent') { + const areaId = matchedChild.area_id || currentNode.fallback_area_id || (await this.getAreaByName('Suporte')).id; + const assignment = await this.queueChat( + chatId, + areaId, + `Roteado pelo fluxo do ${VIRTUAL_AGENT_NAME}: ${matchedChild.title}`, + ); + await this.markBotRoute(chatId, messageId); + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + this.applyBotVariables( + matchedChild.message_text || `Certo, vou encaminhar seu atendimento para ${matchedChild.area_nome || 'a especialidade correta'}.`, + variables, + ), + ), + }; + } + + const assignment = await this.upsertBuilderTriage(chatId, flow.id, matchedChild.id, 0, messageId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + this.applyBotVariables(matchedChild.message_text || 'Pode me contar um pouco mais?', variables), + ), + }; + } + + const attempts = Number(current.routing_attempts || 0); + const maxAttempts = Math.max(1, Number(currentNode.fallback_attempts || 2)); + + if (attempts + 1 >= maxAttempts) { + const fallbackAreaId = currentNode.fallback_area_id || (await this.getAreaByName('Suporte')).id; + const assignment = await this.queueChat( + chatId, + fallbackAreaId, + `Fallback do fluxo do ${VIRTUAL_AGENT_NAME}: ${currentNode.title}`, + ); + await this.markBotRoute(chatId, messageId); + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + this.applyBotVariables( + currentNode.fallback_message || 'Nao consegui identificar com seguranca. Vou encaminhar para um especialista.', + variables, + ), + ), + }; + } + + const assignment = await this.upsertBuilderTriage(chatId, flow.id, currentNode.id, attempts + 1, messageId); + const retryMessage = [currentNode.fallback_message, currentNode.message_text].filter(Boolean).join('\n\n'); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + this.applyBotVariables(retryMessage || 'Nao consegui entender. Pode responder novamente?', variables), + ), + }; + } + + private async routeConfiguredFlow(chatId: string, message: string, current: any, messageId?: string) { + const flow = await this.getActiveTriageFlow(); + if (!flow || !flow.audiences.length) return null; + + if (!current || current.status !== 'bot_triage' || !current.triage_flow_id) { + const assignment = await this.upsertConfiguredTriage(chatId, flow.id, null, 'audience', 1, messageId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + `${flow.greeting_message}\n\n${this.buildAudienceQuestion(flow)}`, + ), + }; + } + + if (Number(current.triage_flow_id) !== flow.id) { + const assignment = await this.upsertConfiguredTriage(chatId, flow.id, null, 'audience', 1, messageId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + `${flow.greeting_message}\n\n${this.buildAudienceQuestion(flow)}`, + ), + }; + } + + if (current.triage_step === 'intent' && current.triage_audience_id) { + return this.routeConfiguredIntent(chatId, message, flow, current, messageId); + } + + if (current.triage_step === 'resolution' && current.triage_intent_id) { + return this.routeConfiguredResolution(chatId, message, flow, current, messageId); + } + + return this.routeConfiguredAudience(chatId, message, flow, current, messageId); + } + + private async routeConfiguredAudience(chatId: string, message: string, flow: TriageFlow, current: any, messageId?: string) { + const audience = this.matchFlowOption(message, flow.audiences); + if (!audience) { + return this.handleConfiguredFallbackOrRetry( + chatId, + flow, + current, + messageId, + null, + 'audience', + this.buildAudienceQuestion(flow, 'Nao consegui identificar seu perfil.'), + ); + } + + const assignment = await this.upsertConfiguredTriage(chatId, flow.id, audience.id, 'intent', 0, messageId); + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + this.buildIntentQuestion(flow, audience), + ), + }; + } + + private async routeConfiguredIntent(chatId: string, message: string, flow: TriageFlow, current: any, messageId?: string) { + const audience = flow.audiences.find((item) => Number(item.id) === Number(current.triage_audience_id)); + if (!audience) { + const assignment = await this.upsertConfiguredTriage(chatId, flow.id, null, 'audience', 1, messageId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage('Atendente virtual', VIRTUAL_AGENT_NAME, this.buildAudienceQuestion(flow)), + }; + } + + const intent = this.matchFlowOption(message, audience.intents); + if (!intent) { + return this.handleConfiguredFallbackOrRetry( + chatId, + flow, + current, + messageId, + audience.id, + 'intent', + this.buildIntentQuestion(flow, audience, 'Nao consegui identificar o assunto.'), + ); + } + + const assignment = await this.upsertConfiguredTriage( + chatId, + flow.id, + audience.id, + 'resolution', + 0, + messageId, + intent.id, + ); + + const responseMessage = intent.response_message || 'Tenho uma orientacao inicial para esse assunto.'; + const resolutionQuestion = + intent.resolution_question || + flow.resolution_question || + 'Essa informacao resolveu sua duvida? Responda 1 para encerrar ou 2 para falar com um especialista.'; + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + `${responseMessage}\n\n${resolutionQuestion}`, + ), + }; + } + + private async routeConfiguredResolution(chatId: string, message: string, flow: TriageFlow, current: any, messageId?: string) { + const intent = flow.audiences + .flatMap((audience) => audience.intents) + .find((item) => Number(item.id) === Number(current.triage_intent_id)); + + if (!intent) { + const fallbackAreaId = flow.fallback_area_id || (await this.getAreaByName('Suporte')).id; + const assignment = await this.queueChat(chatId, fallbackAreaId, `Fallback configurado pelo ${VIRTUAL_AGENT_NAME}`); + await this.markBotRoute(chatId, messageId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage('Atendente virtual', VIRTUAL_AGENT_NAME, flow.fallback_message), + }; + } + + const normalized = this.normalize(message); + const wantsEscalation = + normalized === '2' || + normalized.includes('nao') || + normalized.includes('especialista') || + normalized.includes('atendente') || + normalized.includes('pessoa') || + normalized.includes('humano') || + normalized.includes('falar com alguem'); + const wantsClose = + normalized === '1' || + normalized.includes('sim') || + normalized === 'resolveu' || + normalized.includes('resolvido') || + normalized.includes('encerrar') || + normalized.includes('pode encerrar') || + 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], + ); + const assignment = await this.getAssignment(chatId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + 'Perfeito, vou encerrar por aqui. Se precisar de algo mais, e so chamar novamente.', + ), + }; + } + + const assignment = await this.queueChat( + chatId, + intent.area_id, + `Cliente solicitou especialista apos orientacao do ${VIRTUAL_AGENT_NAME}: ${intent.label}`, + ); + await this.markBotRoute(chatId, messageId); + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + VIRTUAL_AGENT_NAME, + intent.escalation_message || `Certo, vou encaminhar seu atendimento para ${intent.area_nome}.`, + ), + }; + } + + private async handleConfiguredFallbackOrRetry( + chatId: string, + flow: TriageFlow, + current: any, + messageId: string | undefined, + audienceId: number | null, + step: 'audience' | 'intent', + retryMessage: string, + ) { + const attempts = Number(current?.routing_attempts || 0); + if (attempts >= Number(flow.max_attempts || 2)) { + const fallbackAreaId = flow.fallback_area_id || (await this.getAreaByName('Suporte')).id; + const assignment = await this.queueChat(chatId, fallbackAreaId, `Fallback configurado pelo ${VIRTUAL_AGENT_NAME}`); + await this.markBotRoute(chatId, messageId); + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage('Atendente virtual', VIRTUAL_AGENT_NAME, flow.fallback_message), + }; + } + + const assignment = await this.upsertConfiguredTriage(chatId, flow.id, audienceId, step, attempts + 1, messageId); + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage('Atendente virtual', VIRTUAL_AGENT_NAME, retryMessage), + }; + } + + private matchFlowOption(message: string, options: T[]) { + const normalized = this.normalize(message).trim(); + const selectedNumber = Number(normalized.match(/^\d+$/)?.[0] || 0); + if (selectedNumber > 0) { + return options.find((option) => Number(option.sort_order) === selectedNumber) || null; + } + + return options.find((option) => { + const values = [option.label, ...(option.keywords || '').split(',')] + .map((value) => this.normalize(value).trim()) + .filter(Boolean); + return values.some((value) => normalized.includes(value)); + }) || null; + } + + private buildAudienceQuestion(flow: TriageFlow, prefix?: string) { + const options = flow.audiences + .map((audience) => `${audience.sort_order} - ${audience.label}`) + .join('\n'); + return [prefix, flow.audience_question, options].filter(Boolean).join('\n\n'); + } + + private buildIntentQuestion(flow: TriageFlow, audience: TriageAudience, prefix?: string) { + const options = audience.intents + .map((intent) => `${intent.sort_order} - ${intent.label}`) + .join('\n'); + return [prefix, flow.intent_question_template, options].filter(Boolean).join('\n\n'); + } + + 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 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 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)], + ); + + return { + ...flow, + audiences: audiences.rows.map((audience) => ({ + ...audience, + intents: intents.rows.filter((intent) => Number(intent.audience_id) === Number(audience.id)), + })), + }; + } + + 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 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 byId = new Map(); + nodeResult.rows.forEach((node) => byId.set(Number(node.id), { ...node, children: [] })); + byId.forEach((node) => { + if (node.parent_id && byId.has(Number(node.parent_id))) { + byId.get(Number(node.parent_id))?.children.push(node); + } + }); + + const root = byId.get(Number(version.root_node_id)); + if (!root) return null; + + return { + id: Number(version.id), + root_node_id: Number(version.root_node_id), + version_number: Number(version.version_number || 0), + nodes: Array.from(byId.values()), + root, + }; + } + + private matchBuilderChild(message: string, children: BotFlowNode[]) { + const normalized = this.normalize(message).trim(); + return children.find((child) => { + const keywords = String(child.keywords || '') + .split(',') + .map((keyword) => this.normalize(keyword).trim()) + .filter(Boolean); + return keywords.some((keyword) => normalized.includes(keyword)); + }) || null; + } + private async detectKnownArea(message: string) { const normalized = this.normalize(message); + const configuredArea = await this.detectConfiguredArea(normalized); + if (configuredArea) return configuredArea; + const targetName = this.matchAny(normalized, FINANCE_KEYWORDS) ? 'Financeiro' : this.matchAny(normalized, SALES_KEYWORDS) @@ -374,6 +1049,28 @@ export class WhatsappAssignmentService implements OnModuleInit { return this.getAreaByName(targetName); } + 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 match = result.rows.find((row) => normalizedMessage.includes(this.normalize(row.keyword))); + if (!match) return null; + + return { + id: match.id, + name: match.nome.toLowerCase(), + article: this.getAreaArticle(match.nome), + }; + } + private async assertUserCanReceiveAssignment(userId: number) { const canReceive = await this.agentPresenceService.isAvailable(userId); if (!canReceive) { @@ -391,7 +1088,7 @@ export class WhatsappAssignmentService implements OnModuleInit { return { id: result.rows[0].id, name: result.rows[0].nome.toLowerCase(), - article: result.rows[0].nome === 'Comercial' ? 'o' : 'o', + article: this.getAreaArticle(result.rows[0].nome), }; } @@ -423,6 +1120,98 @@ export class WhatsappAssignmentService implements OnModuleInit { return this.enrichAssignment(result.rows[0]); } + private async upsertConfiguredTriage( + chatId: string, + flowId: number, + audienceId: number | null, + step: 'audience' | 'intent' | 'resolution', + attempts: number, + 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, [ + chatId, + attempts, + messageId || null, + flowId, + audienceId, + intentId, + step, + ]); + return this.enrichAssignment(result.rows[0]); + } + + private async upsertBuilderTriage( + chatId: string, + versionId: number, + nodeId: number, + 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, + ]); + return this.enrichAssignment(result.rows[0]); + } + private async markBotRoute(chatId: string, messageId?: string, updateBotTimestamp = true) { await this.db.query( ` @@ -455,6 +1244,34 @@ export class WhatsappAssignmentService implements OnModuleInit { return keywords.some((keyword) => text.includes(this.normalize(keyword))); } + private getAreaArticle(areaName: string) { + const normalized = this.normalize(areaName); + return ['ferias'].includes(normalized) ? 'as' : 'o'; + } + + private applyBotVariables(message: string, variables: BotMessageVariables) { + const firstName = this.getFirstName(variables.nome); + const fullName = this.cleanVariable(variables.nome); + const phone = this.cleanVariable(variables.telefone); + + return String(message || '') + .replace(/\{nome\}/gi, firstName || 'tudo bem') + .replace(/\{nome_completo\}/gi, fullName || firstName || '') + .replace(/\{telefone\}/gi, phone || ''); + } + + private getFirstName(value?: string | null) { + const cleaned = this.cleanVariable(value); + if (!cleaned || /^\d+$/.test(cleaned)) return ''; + return cleaned.split(/\s+/)[0] || ''; + } + + private cleanVariable(value?: string | null) { + const text = String(value || '').trim(); + if (!text || text.includes('@')) return ''; + return text; + } + private normalize(value: string) { return String(value || '') .normalize('NFD') diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts index 3621317..63953b0 100644 --- a/src/modules/whatsapp/whatsapp.controller.ts +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -54,6 +54,11 @@ export class WhatsappController { return this.assignmentService.releaseChat(chatId); } + @Post('close') + async closeChat(@Body() body: { chatId: string; userId?: string | number | null }) { + return this.assignmentService.closeChat(body.chatId, body.userId); + } + @Get('assignment/:chatId') async getAssignment(@Param('chatId') chatId: string) { return this.assignmentService.getAssignment(chatId); @@ -65,13 +70,13 @@ export class WhatsappController { } @Post('templates') - async saveTemplate(@Body() body: { name: string; content: string; areaId?: number | null; requestedByRole?: string }) { - return this.whatsappService.saveTemplate(body.name, body.content, body.areaId, body.requestedByRole); + 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); } @Post('templates/update/:id') - async updateTemplate(@Param('id') id: string, @Body() body: { name: string; content: string; areaId?: number | null }) { - return this.whatsappService.updateTemplate(Number(id), body.name, body.content, body.areaId); + 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); } @Post('templates/approve-admin/:id') diff --git a/src/modules/whatsapp/whatsapp.service.ts b/src/modules/whatsapp/whatsapp.service.ts index 1c3b88e..671fcd3 100644 --- a/src/modules/whatsapp/whatsapp.service.ts +++ b/src/modules/whatsapp/whatsapp.service.ts @@ -30,6 +30,7 @@ export class WhatsappService implements OnModuleInit { 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), @@ -42,6 +43,7 @@ export class WhatsappService implements OnModuleInit { `); 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), @@ -189,9 +191,14 @@ export class WhatsappService implements OnModuleInit { .catch(() => undefined) .then(async () => { try { - const routeResult = await this.assignmentService.routeIncomingMessage(remoteJid, messageBody, messageId); + const routeResult = await this.assignmentService.routeIncomingMessage( + remoteJid, + messageBody, + messageId, + await this.getBotMessageVariables(remoteJid, msg), + ); if (routeResult.shouldSendBotMessage && routeResult.botMessage) { - this.logger.log(`Omnino roteou ${remoteJid} para area ${routeResult.assignment?.area_nome || routeResult.assignment?.area_id}`); + this.logger.log(`Agente Virtual Sothis roteou ${remoteJid} para area ${routeResult.assignment?.area_nome || routeResult.assignment?.area_id}`); await msg.reply(routeResult.botMessage); } } catch (err) { @@ -434,6 +441,19 @@ export class WhatsappService implements OnModuleInit { } } + private async getBotMessageVariables(chatId: string, msg: any) { + const contactProfile = await this.getCustomerContact(chatId); + const notifyName = String(msg?.['_data']?.notifyName || '').trim(); + const pushName = String(msg?.['_data']?.pushName || '').trim(); + const phone = contactProfile?.phone || await this.resolveContactPhone(chatId); + const fallbackName = notifyName || pushName || ''; + + return { + nome: contactProfile?.name || fallbackName, + telefone: phone, + }; + } + async getChatMessages(chatId: string) { if (this.status !== 'CONNECTED') return []; @@ -556,20 +576,35 @@ export class WhatsappService implements OnModuleInit { const renderedContent = this.renderTemplateContent(template.content, variables); const sentMessage = await this.sendMessage(to, renderedContent); - const assignment = await this.assignmentService.assignChat(to, userId, areaId || null); - const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to); + const chatId = this.getSentMessageChatId(sentMessage, to); + const assignment = await this.assignmentService.assignChat(chatId, userId, areaId || null); + const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(chatId); return { - chatId: to, + chatId, template: { ...template, content: renderedContent }, messageId: sentMessage?.id?._serialized || null, assignment: lockedAssignment || assignment, }; } + private getSentMessageChatId(sentMessage: any, fallbackChatId: string) { + const remote = + sentMessage?.id?.remote || + sentMessage?._data?.id?.remote || + sentMessage?.to || + sentMessage?.from || + fallbackChatId; + + return String(remote || fallbackChatId); + } + private renderTemplateContent(content: string, variables?: Record) { - return String(content || '').replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => { - const value = variables?.[key] ?? variables?.[String(key).toLowerCase()]; + return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => { + const cleanKey = String(key || '').trim(); + const lowerKey = cleanKey.toLowerCase(); + const asciiKey = lowerKey.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + const value = variables?.[cleanKey] ?? variables?.[lowerKey] ?? variables?.[asciiKey]; const normalized = String(value || '').trim(); return normalized || match; }); @@ -591,7 +626,7 @@ export class WhatsappService implements OnModuleInit { } async getTemplates() { - await this.refreshFakeMetaApprovals(); + await this.refreshMetaApprovals(); const res = await this.db.query(` SELECT wt.*, @@ -604,12 +639,12 @@ export class WhatsappService implements OnModuleInit { } private async getTemplateById(id: number) { - await this.refreshFakeMetaApprovals(); + 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') { + 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'; @@ -619,6 +654,7 @@ export class WhatsappService implements OnModuleInit { INSERT INTO whatsapp_templates ( name, content, + category, area_id, status, requested_by_role, @@ -627,9 +663,10 @@ export class WhatsappService implements OnModuleInit { meta_approved_at, updated_at ) - VALUES ($1, $2, $3, $4, $5, ${adminApprovedAt}, ${metaSubmittedAt}, NULL, CURRENT_TIMESTAMP) + 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, @@ -639,28 +676,29 @@ export class WhatsappService implements OnModuleInit { updated_at = CURRENT_TIMESTAMP RETURNING * `, - [name, content, areaId || null, status, requestedByRole] + [name, content, this.normalizeTemplateCategory(category), areaId || null, status, requestedByRole] ); return res.rows[0]; } - async updateTemplate(id: number, name: string, content: string, areaId?: number | null) { + 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, - area_id = $3, + 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 = $4 + WHERE id = $5 RETURNING * `, - [name, content, areaId || null, id] + [name, content, this.normalizeTemplateCategory(category), areaId || null, id] ); return res.rows[0]; } @@ -703,7 +741,12 @@ export class WhatsappService implements OnModuleInit { return { success: true }; } - private async refreshFakeMetaApprovals() { + 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