FEAT: adiciona flow builder, auditoria e melhorias no WhatsApp
All checks were successful
Deploy Dev / deploy (push) Successful in 3s
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:
parent
1e28ecc349
commit
5a21257191
@ -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();
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
132
src/modules/admin/knowledge-base.controller.ts
Normal file
132
src/modules/admin/knowledge-base.controller.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
878
src/modules/admin/knowledge-base.service.ts
Normal file
878
src/modules/admin/knowledge-base.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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')
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user