FEAT: adiciona flow builder, auditoria e melhorias no WhatsApp
All checks were successful
Deploy Dev / deploy (push) Successful in 3s

- cria endpoints administrativos para fluxo do bot, conteúdos da IA, auditoria e ranking de atendentes
- substitui triagem fixa por interpretação de árvore configurável
- adiciona encerramento de atendimento, categorias de template e variáveis de mensagem
- corrige abertura ativa para usar o chatId real retornado pelo WhatsApp
This commit is contained in:
Rafael Alves Lopes 2026-05-26 09:08:20 -03:00
parent 1e28ecc349
commit 5a21257191
8 changed files with 2235 additions and 33 deletions

View File

@ -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'; import { AdminAccessService } from './admin-access.service';
@Controller('admin/access') @Controller('admin/access')
@ -15,6 +15,40 @@ export class AdminAccessController {
return this.adminAccessService.getOverview(); 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') @Get('areas')
listAreas() { listAreas() {
return this.adminAccessService.listAreas(); return this.adminAccessService.listAreas();
@ -33,6 +67,11 @@ export class AdminAccessController {
return this.adminAccessService.updateArea(Number(id), body); return this.adminAccessService.updateArea(Number(id), body);
} }
@Delete('areas/:id')
deleteArea(@Param('id') id: string) {
return this.adminAccessService.deleteArea(Number(id));
}
@Get('users') @Get('users')
listUsers() { listUsers() {
return this.adminAccessService.listUsers(); return this.adminAccessService.listUsers();

View File

@ -20,10 +20,57 @@ interface AreaInput {
ativo?: boolean; 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() @Injectable()
export class AdminAccessService { export class AdminAccessService {
constructor(private readonly database: DatabaseService) {} 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() { async getOptions() {
const [profiles, areas] = await Promise.all([ const [profiles, areas] = await Promise.all([
this.database.query<{ id: number; nome: string }>( 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() { async listAreas() {
const result = await this.database.query( 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 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_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 LEFT JOIN usuarios su ON su.id = sua.usuario_id
WHERE a.ativo = TRUE
GROUP BY a.id, r.nome GROUP BY a.id, r.nome
ORDER BY a.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)); 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) INSERT INTO areas (nome, descricao, responsavel_usuario_id, ativo, created_at, updated_at)
VALUES ($1, $2, $3, TRUE, NOW(), NOW()) 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 * RETURNING *
`, `,
[nome, this.normalizeText(input.descricao), input.responsavelUsuarioId || null], [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.ensureAreaSupervisor(input.responsavelUsuarioId, result.rows[0].id);
} }
await this.logAudit('Especialidade criada', 'Especialidade', String(result.rows[0].id), nome);
return this.listAreas(); return this.listAreas();
} }
@ -299,9 +537,45 @@ export class AdminAccessService {
await this.ensureAreaSupervisor(input.responsavelUsuarioId, areaId); await this.ensureAreaSupervisor(input.responsavelUsuarioId, areaId);
} }
await this.logAudit('Especialidade atualizada', 'Especialidade', String(areaId), input.nome || result.rows[0]?.nome || '');
return this.listAreas(); 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) { private async ensureAreaSupervisor(usuarioId: number, areaId: number) {
const supervisorProfile = await this.database.query<{ id: number }>( const supervisorProfile = await this.database.query<{ id: number }>(
`SELECT id FROM perfis_acesso WHERE nome = 'Supervisor' LIMIT 1`, `SELECT id FROM perfis_acesso WHERE nome = 'Supervisor' LIMIT 1`,

View File

@ -7,10 +7,24 @@ import { AgentPresenceController } from './agent-presence.controller';
import { AgentPresenceService } from './agent-presence.service'; import { AgentPresenceService } from './agent-presence.service';
import { CustomerContactsController } from './customer-contacts.controller'; import { CustomerContactsController } from './customer-contacts.controller';
import { CustomerContactsService } from './customer-contacts.service'; import { CustomerContactsService } from './customer-contacts.service';
import { KnowledgeBaseController } from './knowledge-base.controller';
import { KnowledgeBaseService } from './knowledge-base.service';
@Module({ @Module({
controllers: [AdminAccessController, AgentNotesController, AgentPresenceController, CustomerContactsController], controllers: [
providers: [AdminAccessService, AgentNotesService, AgentPresenceService, CustomerContactsService], AdminAccessController,
AgentNotesController,
AgentPresenceController,
CustomerContactsController,
KnowledgeBaseController,
],
providers: [
AdminAccessService,
AgentNotesService,
AgentPresenceService,
CustomerContactsService,
KnowledgeBaseService,
],
exports: [AgentPresenceService], exports: [AgentPresenceService],
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -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));
}
}

View File

@ -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<number, number>();
const childrenByParent = new Map<number, any[]>();
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<void> => {
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<number, any>();
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<number, any[]>();
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<string, string>();
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<string, string>();
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 };
}
}

View File

@ -9,6 +9,72 @@ interface TransferInput {
note?: string | null; 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 = [ const SUPPORT_KEYWORDS = [
'suporte', 'suporte',
'bug', '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_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 reserved_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS pause_released_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; 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_user_id = NULL,
reserved_at = NULL, reserved_at = NULL,
pause_released_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, assigned_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
RETURNING *; RETURNING *;
@ -134,6 +212,12 @@ export class WhatsappAssignmentService implements OnModuleInit {
ELSE whatsapp_chat_atribuicoes.expires_at ELSE whatsapp_chat_atribuicoes.expires_at
END, END,
transfer_note = EXCLUDED.transfer_note, 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 updated_at = CURRENT_TIMESTAMP
RETURNING *; RETURNING *;
`; `;
@ -161,6 +245,12 @@ export class WhatsappAssignmentService implements OnModuleInit {
reserved_user_id = NULL, reserved_user_id = NULL,
reserved_at = NULL, reserved_at = NULL,
pause_released_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, assigned_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
RETURNING *; RETURNING *;
@ -187,6 +277,10 @@ export class WhatsappAssignmentService implements OnModuleInit {
if (!assignment) return null; 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()) { if (assignment.expires_at && new Date(assignment.expires_at).getTime() <= Date.now()) {
await this.db.query( await this.db.query(
`UPDATE whatsapp_chat_atribuicoes SET status = 'expired', user_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE chat_id = $1`, `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_user_id = NULL,
reserved_at = NULL, reserved_at = NULL,
pause_released_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 updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1 WHERE chat_id = $1
RETURNING *; RETURNING *;
@ -216,6 +316,42 @@ export class WhatsappAssignmentService implements OnModuleInit {
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null; return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
} }
async closeChat(chatId: string, userId?: string | number | null) {
this.logger.log(`Encerrando chat ${chatId}`);
const params: Array<string | number | null> = [chatId];
const userGuard = userId ? 'AND (user_id = $2 OR user_id IS NULL)' : '';
if (userId) params.push(Number(userId));
const result = await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
user_id = NULL,
area_id = NULL,
status = 'expired',
awaiting_customer_reply = FALSE,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
triage_flow_id = NULL,
triage_audience_id = NULL,
triage_intent_id = NULL,
triage_step = NULL,
triage_builder_version_id = NULL,
triage_builder_node_id = NULL,
transfer_note = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
${userGuard}
RETURNING *
`,
params,
);
return result.rows[0] || null;
}
async clearTransferNote(chatId: string) { async clearTransferNote(chatId: string) {
const result = await this.db.query( const result = await this.db.query(
` `
@ -263,7 +399,7 @@ export class WhatsappAssignmentService implements OnModuleInit {
return !assignment?.awaiting_customer_reply; 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(); const cleanMessage = (message || '').trim();
if (!cleanMessage) { if (!cleanMessage) {
const current = await this.getAssignment(chatId); const current = await this.getAssignment(chatId);
@ -280,10 +416,20 @@ export class WhatsappAssignmentService implements OnModuleInit {
return { assignment: current, shouldSendBotMessage: false, botMessage: null }; 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); const detectedArea = await this.detectKnownArea(cleanMessage);
if (detectedArea) { 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); await this.markBotRoute(chatId, messageId);
const hasPreviousTriage = current?.status === 'bot_triage'; const hasPreviousTriage = current?.status === 'bot_triage';
const botMessage = hasPreviousTriage const botMessage = hasPreviousTriage
@ -293,7 +439,7 @@ export class WhatsappAssignmentService implements OnModuleInit {
return { return {
assignment, assignment,
shouldSendBotMessage: true, 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, shouldSendBotMessage: true,
botMessage: this.formatSenderMessage( botMessage: this.formatSenderMessage(
'Atendente virtual', 'Atendente virtual',
'Omnino', VIRTUAL_AGENT_NAME,
'Nao consegui identificar a area ideal com seguranca. Vou te encaminhar para o suporte para agilizar seu atendimento.', '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, shouldSendBotMessage: true,
botMessage: this.formatSenderMessage( botMessage: this.formatSenderMessage(
'Atendente virtual', 'Atendente virtual',
'Omnino', VIRTUAL_AGENT_NAME,
'Para eu te encaminhar corretamente, responda por favor com uma destas opcoes: suporte, financeiro ou comercial.', '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 assignment = await this.upsertTriage(chatId, 1, messageId);
const intentMessage = 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 { return {
assignment, assignment,
shouldSendBotMessage: true, 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; 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<T extends { sort_order: number; keywords?: string; label: string }>(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<TriageFlow | null> {
const flows = await this.db.query<any>(
`
SELECT *
FROM bot_triage_flows
WHERE active = TRUE
ORDER BY id ASC
LIMIT 1
`,
).catch(() => ({ rows: [] }));
const flow = flows.rows[0];
if (!flow) return null;
const audiences = await this.db.query<any>(
`
SELECT *
FROM bot_triage_audiences
WHERE flow_id = $1
AND active = TRUE
ORDER BY sort_order ASC, id ASC
`,
[flow.id],
);
const intents = await this.db.query<any>(
`
SELECT
bti.*,
a.nome AS area_nome
FROM bot_triage_intents bti
INNER JOIN areas a ON a.id = bti.area_id
WHERE bti.audience_id = ANY($1::int[])
AND bti.active = TRUE
ORDER BY bti.sort_order ASC, bti.id ASC
`,
[audiences.rows.map((audience) => audience.id)],
);
return {
...flow,
audiences: audiences.rows.map((audience) => ({
...audience,
intents: intents.rows.filter((intent) => Number(intent.audience_id) === Number(audience.id)),
})),
};
}
private async getPublishedBotFlow(): Promise<BotFlowVersion | null> {
const versionResult = await this.db.query<any>(
`
SELECT *
FROM bot_flow_versions
WHERE status = 'published'
AND root_node_id IS NOT NULL
ORDER BY published_at DESC, id DESC
LIMIT 1
`,
).catch(() => ({ rows: [] }));
const version = versionResult.rows[0];
if (!version) return null;
const nodeResult = await this.db.query<any>(
`
SELECT
node.*,
area.nome AS area_nome,
fallback_area.nome AS fallback_area_nome
FROM bot_flow_nodes node
LEFT JOIN areas area ON area.id = node.area_id
LEFT JOIN areas fallback_area ON fallback_area.id = node.fallback_area_id
WHERE node.version_id = $1
ORDER BY node.parent_id NULLS FIRST, node.sort_order ASC, node.id ASC
`,
[version.id],
).catch(() => ({ rows: [] }));
const byId = new Map<number, BotFlowNode>();
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) { private async detectKnownArea(message: string) {
const normalized = this.normalize(message); const normalized = this.normalize(message);
const configuredArea = await this.detectConfiguredArea(normalized);
if (configuredArea) return configuredArea;
const targetName = this.matchAny(normalized, FINANCE_KEYWORDS) const targetName = this.matchAny(normalized, FINANCE_KEYWORDS)
? 'Financeiro' ? 'Financeiro'
: this.matchAny(normalized, SALES_KEYWORDS) : this.matchAny(normalized, SALES_KEYWORDS)
@ -374,6 +1049,28 @@ export class WhatsappAssignmentService implements OnModuleInit {
return this.getAreaByName(targetName); 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) { private async assertUserCanReceiveAssignment(userId: number) {
const canReceive = await this.agentPresenceService.isAvailable(userId); const canReceive = await this.agentPresenceService.isAvailable(userId);
if (!canReceive) { if (!canReceive) {
@ -391,7 +1088,7 @@ export class WhatsappAssignmentService implements OnModuleInit {
return { return {
id: result.rows[0].id, id: result.rows[0].id,
name: result.rows[0].nome.toLowerCase(), 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]); 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) { private async markBotRoute(chatId: string, messageId?: string, updateBotTimestamp = true) {
await this.db.query( await this.db.query(
` `
@ -455,6 +1244,34 @@ export class WhatsappAssignmentService implements OnModuleInit {
return keywords.some((keyword) => text.includes(this.normalize(keyword))); 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) { private normalize(value: string) {
return String(value || '') return String(value || '')
.normalize('NFD') .normalize('NFD')

View File

@ -54,6 +54,11 @@ export class WhatsappController {
return this.assignmentService.releaseChat(chatId); 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') @Get('assignment/:chatId')
async getAssignment(@Param('chatId') chatId: string) { async getAssignment(@Param('chatId') chatId: string) {
return this.assignmentService.getAssignment(chatId); return this.assignmentService.getAssignment(chatId);
@ -65,13 +70,13 @@ export class WhatsappController {
} }
@Post('templates') @Post('templates')
async saveTemplate(@Body() body: { name: string; content: string; areaId?: number | null; requestedByRole?: string }) { 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); return this.whatsappService.saveTemplate(body.name, body.content, body.areaId, body.requestedByRole, body.category);
} }
@Post('templates/update/:id') @Post('templates/update/:id')
async updateTemplate(@Param('id') id: string, @Body() body: { name: string; content: string; areaId?: number | null }) { 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); return this.whatsappService.updateTemplate(Number(id), body.name, body.content, body.areaId, body.category);
} }
@Post('templates/approve-admin/:id') @Post('templates/approve-admin/:id')

View File

@ -30,6 +30,7 @@ export class WhatsappService implements OnModuleInit {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL UNIQUE,
content TEXT NOT NULL, content TEXT NOT NULL,
category VARCHAR(40) NOT NULL DEFAULT 'UTILITY',
area_id INTEGER REFERENCES areas (id) ON DELETE SET NULL, area_id INTEGER REFERENCES areas (id) ON DELETE SET NULL,
status VARCHAR(40) NOT NULL DEFAULT 'approved', status VARCHAR(40) NOT NULL DEFAULT 'approved',
requested_by_role VARCHAR(40), requested_by_role VARCHAR(40),
@ -42,6 +43,7 @@ export class WhatsappService implements OnModuleInit {
`); `);
await this.db.query(` await this.db.query(`
ALTER TABLE whatsapp_templates 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 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 status VARCHAR(40) NOT NULL DEFAULT 'approved',
ADD COLUMN IF NOT EXISTS requested_by_role VARCHAR(40), ADD COLUMN IF NOT EXISTS requested_by_role VARCHAR(40),
@ -189,9 +191,14 @@ export class WhatsappService implements OnModuleInit {
.catch(() => undefined) .catch(() => undefined)
.then(async () => { .then(async () => {
try { 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) { 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); await msg.reply(routeResult.botMessage);
} }
} catch (err) { } 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) { async getChatMessages(chatId: string) {
if (this.status !== 'CONNECTED') return []; if (this.status !== 'CONNECTED') return [];
@ -556,20 +576,35 @@ export class WhatsappService implements OnModuleInit {
const renderedContent = this.renderTemplateContent(template.content, variables); const renderedContent = this.renderTemplateContent(template.content, variables);
const sentMessage = await this.sendMessage(to, renderedContent); const sentMessage = await this.sendMessage(to, renderedContent);
const assignment = await this.assignmentService.assignChat(to, userId, areaId || null); const chatId = this.getSentMessageChatId(sentMessage, to);
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to); const assignment = await this.assignmentService.assignChat(chatId, userId, areaId || null);
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(chatId);
return { return {
chatId: to, chatId,
template: { ...template, content: renderedContent }, template: { ...template, content: renderedContent },
messageId: sentMessage?.id?._serialized || null, messageId: sentMessage?.id?._serialized || null,
assignment: lockedAssignment || assignment, assignment: lockedAssignment || assignment,
}; };
} }
private getSentMessageChatId(sentMessage: any, fallbackChatId: string) {
const remote =
sentMessage?.id?.remote ||
sentMessage?._data?.id?.remote ||
sentMessage?.to ||
sentMessage?.from ||
fallbackChatId;
return String(remote || fallbackChatId);
}
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) { private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
return String(content || '').replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => { return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => {
const value = variables?.[key] ?? variables?.[String(key).toLowerCase()]; 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(); const normalized = String(value || '').trim();
return normalized || match; return normalized || match;
}); });
@ -591,7 +626,7 @@ export class WhatsappService implements OnModuleInit {
} }
async getTemplates() { async getTemplates() {
await this.refreshFakeMetaApprovals(); await this.refreshMetaApprovals();
const res = await this.db.query(` const res = await this.db.query(`
SELECT SELECT
wt.*, wt.*,
@ -604,12 +639,12 @@ export class WhatsappService implements OnModuleInit {
} }
private async getTemplateById(id: number) { 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]); const res = await this.db.query('SELECT * FROM whatsapp_templates WHERE id = $1 LIMIT 1', [id]);
return res.rows[0] || null; 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 isSupervisor = requestedByRole === 'supervisor';
const status = isSupervisor ? 'admin_review' : 'meta_review'; const status = isSupervisor ? 'admin_review' : 'meta_review';
const adminApprovedAt = isSupervisor ? null : 'CURRENT_TIMESTAMP'; const adminApprovedAt = isSupervisor ? null : 'CURRENT_TIMESTAMP';
@ -619,6 +654,7 @@ export class WhatsappService implements OnModuleInit {
INSERT INTO whatsapp_templates ( INSERT INTO whatsapp_templates (
name, name,
content, content,
category,
area_id, area_id,
status, status,
requested_by_role, requested_by_role,
@ -627,9 +663,10 @@ export class WhatsappService implements OnModuleInit {
meta_approved_at, meta_approved_at,
updated_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 ON CONFLICT (name) DO UPDATE SET
content = EXCLUDED.content, content = EXCLUDED.content,
category = EXCLUDED.category,
area_id = EXCLUDED.area_id, area_id = EXCLUDED.area_id,
status = EXCLUDED.status, status = EXCLUDED.status,
requested_by_role = EXCLUDED.requested_by_role, requested_by_role = EXCLUDED.requested_by_role,
@ -639,28 +676,29 @@ export class WhatsappService implements OnModuleInit {
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
RETURNING * RETURNING *
`, `,
[name, content, areaId || null, status, requestedByRole] [name, content, this.normalizeTemplateCategory(category), areaId || null, status, requestedByRole]
); );
return res.rows[0]; 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( const res = await this.db.query(
` `
UPDATE whatsapp_templates UPDATE whatsapp_templates
SET SET
name = $1, name = $1,
content = $2, content = $2,
area_id = $3, category = $3,
area_id = $4,
status = 'meta_review', status = 'meta_review',
admin_approved_at = CURRENT_TIMESTAMP, admin_approved_at = CURRENT_TIMESTAMP,
meta_submitted_at = CURRENT_TIMESTAMP, meta_submitted_at = CURRENT_TIMESTAMP,
meta_approved_at = NULL, meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $4 WHERE id = $5
RETURNING * RETURNING *
`, `,
[name, content, areaId || null, id] [name, content, this.normalizeTemplateCategory(category), areaId || null, id]
); );
return res.rows[0]; return res.rows[0];
} }
@ -703,7 +741,12 @@ export class WhatsappService implements OnModuleInit {
return { success: true }; 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(` await this.db.query(`
UPDATE whatsapp_templates UPDATE whatsapp_templates
SET SET