From 377b90d37a3691471257a3920dfbd77fe96e922f Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Fri, 29 May 2026 17:01:35 -0300 Subject: [PATCH] =?UTF-8?q?FEAT:=20Reestruturado=20m=C3=B3dulo=20de=20Know?= =?UTF-8?q?ledge=20Base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Movido fluxo do bot para módulo próprio knowledge-base - Mantido contrato HTTP em /admin/knowledge por compatibilidade - Criado KnowledgeBaseRepository para concentrar SQL - Service agora fica responsável por regra de negócio e orquestração - Removida criação de schema em runtime do fluxo do bot - Atualizada documentação do módulo e wiki backend --- src/app.module.ts | 2 + src/modules/admin/admin.module.ts | 4 - src/modules/admin/knowledge-base.service.ts | 878 ------------------ .../knowledge-base.controller.ts | 0 .../knowledge-base/knowledge-base.module.ts | 11 + .../knowledge-base/knowledge-base.service.ts | 504 ++++++++++ .../repositories/knowledge-base.repository.ts | 529 +++++++++++ 7 files changed, 1046 insertions(+), 882 deletions(-) delete mode 100644 src/modules/admin/knowledge-base.service.ts rename src/modules/{admin => knowledge-base}/knowledge-base.controller.ts (100%) create mode 100644 src/modules/knowledge-base/knowledge-base.module.ts create mode 100644 src/modules/knowledge-base/knowledge-base.service.ts create mode 100644 src/modules/knowledge-base/repositories/knowledge-base.repository.ts diff --git a/src/app.module.ts b/src/app.module.ts index 37a1b99..e3ab847 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import { AppController } from './app.controller'; import { DatabaseModule } from './infra/database/database.module'; import { AdminModule } from './modules/admin/admin.module'; import { AuthModule } from './modules/auth/auth.module'; +import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module'; import { WhatsappModule } from './modules/whatsapp/whatsapp.module'; @Module({ @@ -18,6 +19,7 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module'; DatabaseModule, AuthModule, AdminModule, + KnowledgeBaseModule, WhatsappModule, ], controllers: [AppController], diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 94681ac..90114d4 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -7,8 +7,6 @@ import { AgentPresenceController } from './agent-presence.controller'; import { AgentPresenceService } from './agent-presence.service'; import { CustomerContactsController } from './customer-contacts.controller'; import { CustomerContactsService } from './customer-contacts.service'; -import { KnowledgeBaseController } from './knowledge-base.controller'; -import { KnowledgeBaseService } from './knowledge-base.service'; import { AdminAccessRepository } from './repositories/admin-access.repository'; @Module({ @@ -17,7 +15,6 @@ import { AdminAccessRepository } from './repositories/admin-access.repository'; AgentNotesController, AgentPresenceController, CustomerContactsController, - KnowledgeBaseController, ], providers: [ AdminAccessService, @@ -25,7 +22,6 @@ import { AdminAccessRepository } from './repositories/admin-access.repository'; AgentNotesService, AgentPresenceService, CustomerContactsService, - KnowledgeBaseService, ], exports: [AgentPresenceService], }) diff --git a/src/modules/admin/knowledge-base.service.ts b/src/modules/admin/knowledge-base.service.ts deleted file mode 100644 index 9155133..0000000 --- a/src/modules/admin/knowledge-base.service.ts +++ /dev/null @@ -1,878 +0,0 @@ -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 o time responsável.'; - `).catch(() => undefined); - - await this.database.query(` - CREATE TABLE IF NOT EXISTS bot_flow_versions ( - id SERIAL PRIMARY KEY, - name VARCHAR(160) NOT NULL DEFAULT 'Fluxo RH Sothis', - status VARCHAR(30) NOT NULL DEFAULT 'draft', - version_number INTEGER NOT NULL DEFAULT 0, - root_node_id INTEGER, - published_at TIMESTAMP WITH TIME ZONE, - snapshot JSONB, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP - ); - `); - - await this.database.query(` - CREATE TABLE IF NOT EXISTS bot_flow_nodes ( - id SERIAL PRIMARY KEY, - version_id INTEGER NOT NULL REFERENCES bot_flow_versions(id) ON DELETE CASCADE, - parent_id INTEGER REFERENCES bot_flow_nodes(id) ON DELETE CASCADE, - node_type VARCHAR(30) NOT NULL CHECK (node_type IN ('greeting', 'question', 'agent', 'close')), - title VARCHAR(160) NOT NULL, - message_text TEXT, - keywords TEXT, - fallback_message TEXT, - fallback_attempts INTEGER NOT NULL DEFAULT 2, - fallback_area_id INTEGER REFERENCES areas(id) ON DELETE SET NULL, - area_id INTEGER REFERENCES areas(id) ON DELETE SET NULL, - sort_order INTEGER NOT NULL DEFAULT 1, - position_x INTEGER NOT NULL DEFAULT 0, - position_y INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP - ); - `); - - await this.database.query(` - ALTER TABLE bot_flow_nodes - DROP CONSTRAINT IF EXISTS bot_flow_nodes_node_type_check; - ALTER TABLE bot_flow_nodes - ADD CONSTRAINT bot_flow_nodes_node_type_check - CHECK (node_type IN ('greeting', 'question', 'agent', 'close')); - `).catch(() => undefined); - - await this.database.query(` - ALTER TABLE whatsapp_chat_atribuicoes - ADD COLUMN IF NOT EXISTS triage_builder_version_id INTEGER, - ADD COLUMN IF NOT EXISTS triage_builder_node_id INTEGER; - `).catch(() => undefined); - - await this.ensureDraftBotFlow(); - } - - async getBotFlow() { - const draft = await this.ensureDraftBotFlow(); - const nodes = await this.getFlowNodes(draft.id); - const latestPublished = await this.database.query( - ` - SELECT id, version_number, published_at - FROM bot_flow_versions - WHERE status = 'published' - ORDER BY published_at DESC, id DESC - LIMIT 1 - `, - ); - - return { - ...draft, - nodes, - tree: this.buildNodeTree(nodes, draft.root_node_id), - latestPublished: latestPublished.rows[0] || null, - }; - } - - async listBotFlowVersions() { - const result = await this.database.query( - ` - SELECT id, name, status, version_number, published_at, created_at - FROM bot_flow_versions - WHERE status = 'published' - ORDER BY published_at DESC, id DESC - `, - ); - - return result.rows; - } - - async createBotFlowNode(input: { - parentId: number; - nodeType: 'question' | 'agent' | 'close'; - title?: string; - messageText?: string; - keywords?: string; - fallbackMessage?: string; - fallbackAttempts?: number; - fallbackAreaId?: number | null; - areaId?: number | null; - }) { - const draft = await this.ensureDraftBotFlow(); - const parentId = Number(input.parentId); - const parent = await this.getDraftNode(parentId, draft.id); - if (!parent) throw new BadRequestException('No pai nao encontrado no fluxo.'); - if (parent.node_type === 'agent') throw new BadRequestException('Um no de envio para agente nao pode ter filhos.'); - - const nodeType = input.nodeType; - if (!['question', 'agent', 'close'].includes(nodeType)) { - throw new BadRequestException('Tipo de no invalido.'); - } - - if (nodeType === 'agent' && !input.areaId) { - throw new BadRequestException('Selecione a especialidade de destino.'); - } - - if (nodeType === 'question' && !this.cleanOptional(input.messageText)) { - throw new BadRequestException('Informe a mensagem da pergunta.'); - } - - await this.assertNoKeywordConflict(parentId, this.cleanOptional(input.keywords), null); - const count = await this.database.query<{ total: string }>( - 'SELECT COUNT(*)::TEXT AS total FROM bot_flow_nodes WHERE parent_id = $1', - [parentId], - ); - - const title = - this.cleanOptional(input.title) || - (nodeType === 'agent' ? 'Enviar para agente' : nodeType === 'close' ? 'Encerrar pelo bot' : 'Pergunta'); - - const result = await this.database.query( - ` - INSERT INTO bot_flow_nodes ( - version_id, parent_id, node_type, title, message_text, keywords, - fallback_message, fallback_attempts, fallback_area_id, area_id, - sort_order, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, 2), $9, $10, $11, CURRENT_TIMESTAMP) - RETURNING * - `, - [ - draft.id, - parentId, - nodeType, - title, - this.cleanOptional(input.messageText), - this.cleanOptional(input.keywords), - this.cleanOptional(input.fallbackMessage), - input.fallbackAttempts ? Number(input.fallbackAttempts) : null, - input.fallbackAreaId || null, - input.areaId || null, - Number(count.rows[0]?.total || 0) + 1, - ], - ); - - return result.rows[0]; - } - - async updateBotFlowNode(id: number, input: { - title?: string; - messageText?: string; - keywords?: string; - fallbackMessage?: string; - fallbackAttempts?: number; - fallbackAreaId?: number | null; - areaId?: number | null; - sortOrder?: number; - }) { - const draft = await this.ensureDraftBotFlow(); - const node = await this.getDraftNode(Number(id), draft.id); - if (!node) throw new BadRequestException('No nao encontrado no fluxo.'); - - if (node.node_type === 'greeting' && input.keywords) { - throw new BadRequestException('O no raiz nao usa keywords.'); - } - - if (node.node_type !== 'greeting') { - await this.assertNoKeywordConflict(Number(node.parent_id), this.cleanOptional(input.keywords), Number(id)); - } - - const result = await this.database.query( - ` - UPDATE bot_flow_nodes - SET - title = COALESCE($2, title), - message_text = COALESCE($3, message_text), - keywords = COALESCE($4, keywords), - fallback_message = COALESCE($5, fallback_message), - fallback_attempts = COALESCE($6, fallback_attempts), - fallback_area_id = $7, - area_id = $8, - sort_order = COALESCE($9, sort_order), - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - AND version_id = $10 - RETURNING * - `, - [ - id, - this.cleanOptional(input.title), - this.cleanOptional(input.messageText), - this.cleanOptional(input.keywords), - this.cleanOptional(input.fallbackMessage), - input.fallbackAttempts ? Number(input.fallbackAttempts) : null, - input.fallbackAreaId || null, - input.areaId || null, - input.sortOrder ? Number(input.sortOrder) : null, - draft.id, - ], - ); - - return result.rows[0]; - } - - async deleteBotFlowNode(id: number) { - const draft = await this.ensureDraftBotFlow(); - const node = await this.getDraftNode(Number(id), draft.id); - if (!node) throw new BadRequestException('No nao encontrado no fluxo.'); - if (node.node_type === 'greeting') throw new BadRequestException('O no raiz nao pode ser removido.'); - - await this.database.query('DELETE FROM bot_flow_nodes WHERE id = $1 AND version_id = $2', [id, draft.id]); - return { success: true }; - } - - async publishBotFlow() { - const draft = await this.ensureDraftBotFlow(); - const nodes = await this.getFlowNodes(draft.id); - this.validateFlow(nodes, draft.root_node_id); - - const nextVersion = await this.database.query<{ next_version: number }>( - ` - SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version - FROM bot_flow_versions - WHERE status = 'published' - `, - ); - - const published = await this.database.query( - ` - INSERT INTO bot_flow_versions (name, status, version_number, snapshot, published_at, updated_at) - VALUES ($1, 'published', $2, $3::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - RETURNING * - `, - [draft.name, nextVersion.rows[0].next_version, JSON.stringify({ nodes })], - ); - - const publishedVersion = published.rows[0]; - const idMap = new Map(); - const childrenByParent = new Map(); - nodes.forEach((node) => { - if (!node.parent_id) return; - const parentId = Number(node.parent_id); - childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), node]); - }); - - const rootNode = nodes.find((node) => Number(node.id) === Number(draft.root_node_id)); - if (!rootNode) { - throw new BadRequestException('O fluxo precisa ter um no raiz de saudacao.'); - } - - const copyNode = async (node: any, parentId: number | null): Promise => { - const copied = await this.database.query( - ` - INSERT INTO bot_flow_nodes ( - version_id, parent_id, node_type, title, message_text, keywords, - fallback_message, fallback_attempts, fallback_area_id, area_id, - sort_order, position_x, position_y, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, CURRENT_TIMESTAMP) - RETURNING * - `, - [ - publishedVersion.id, - parentId, - node.node_type, - node.title, - node.message_text, - node.keywords, - node.fallback_message, - node.fallback_attempts, - node.fallback_area_id, - node.area_id, - node.sort_order, - node.position_x, - node.position_y, - ], - ); - const copiedId = Number(copied.rows[0].id); - idMap.set(Number(node.id), copiedId); - - const children = childrenByParent.get(Number(node.id)) || []; - for (const child of children) { - await copyNode(child, copiedId); - } - }; - - await copyNode(rootNode, null); - - const rootNodeId = idMap.get(Number(draft.root_node_id)); - await this.database.query( - 'UPDATE bot_flow_versions SET root_node_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1', - [publishedVersion.id, rootNodeId || null], - ); - - return this.getBotFlow(); - } - - async quickPublishBotFlow() { - const draft = await this.ensureDraftBotFlow(); - const root = await this.getDraftNode(Number(draft.root_node_id), draft.id); - if (!root) throw new BadRequestException('O fluxo precisa ter um no raiz de saudacao.'); - - const supportArea = await this.database.query<{ id: number }>( - `SELECT id FROM areas WHERE nome = 'Suporte' ORDER BY id ASC LIMIT 1`, - ); - const fallbackAreaId = supportArea.rows[0]?.id; - if (!fallbackAreaId) throw new BadRequestException('Cadastre a especialidade Suporte antes de publicar um fluxo simples.'); - - const childCount = await this.database.query<{ total: string }>( - 'SELECT COUNT(*)::TEXT AS total FROM bot_flow_nodes WHERE parent_id = $1', - [root.id], - ); - - if (Number(childCount.rows[0]?.total || 0) === 0) { - await this.createBotFlowNode({ - parentId: root.id, - nodeType: 'agent', - title: 'Fallback para atendimento humano', - keywords: '1, atendimento, humano, ajuda, rh', - areaId: fallbackAreaId, - }); - } - - return this.publishBotFlow(); - } - - async getTriageFlow() { - const flowResult = await this.database.query(` - SELECT * - FROM bot_triage_flows - WHERE active = TRUE - ORDER BY id ASC - LIMIT 1 - `); - - const flow = flowResult.rows[0]; - if (!flow) return null; - - const audienceResult = await this.database.query( - ` - SELECT * - FROM bot_triage_audiences - WHERE flow_id = $1 - ORDER BY sort_order ASC, id ASC - `, - [flow.id], - ); - - const audienceIds = audienceResult.rows.map((audience) => audience.id); - const intentResult = audienceIds.length - ? await this.database.query( - ` - SELECT - bti.*, - a.nome AS area_nome - FROM bot_triage_intents bti - INNER JOIN areas a ON a.id = bti.area_id - WHERE bti.audience_id = ANY($1::int[]) - ORDER BY bti.sort_order ASC, bti.id ASC - `, - [audienceIds], - ) - : { rows: [] }; - - return { - ...flow, - audiences: audienceResult.rows.map((audience) => ({ - ...audience, - intents: intentResult.rows.filter((intent) => Number(intent.audience_id) === Number(audience.id)), - })), - }; - } - - async updateTriageFlow(input: { - greetingMessage?: string; - audienceQuestion?: string; - intentQuestionTemplate?: string; - resolutionQuestion?: string; - fallbackMessage?: string; - fallbackAreaId?: number | null; - maxAttempts?: number; - }) { - const flow = await this.getTriageFlow() as any; - if (!flow) { - throw new BadRequestException('Fluxo de triagem nao encontrado.'); - } - - const result = await this.database.query( - ` - UPDATE bot_triage_flows - SET - greeting_message = COALESCE($2, greeting_message), - audience_question = COALESCE($3, audience_question), - intent_question_template = COALESCE($4, intent_question_template), - resolution_question = COALESCE($5, resolution_question), - fallback_message = COALESCE($6, fallback_message), - fallback_area_id = $7, - max_attempts = COALESCE($8, max_attempts), - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING * - `, - [ - flow.id, - this.cleanOptional(input.greetingMessage), - this.cleanOptional(input.audienceQuestion), - this.cleanOptional(input.intentQuestionTemplate), - this.cleanOptional(input.resolutionQuestion), - this.cleanOptional(input.fallbackMessage), - input.fallbackAreaId || null, - input.maxAttempts ? Number(input.maxAttempts) : null, - ], - ); - - return result.rows[0]; - } - - async createAudience(input: { label: string; keywords?: string; sortOrder?: number }) { - const flow = await this.getTriageFlow() as any; - if (!flow) throw new BadRequestException('Fluxo de triagem nao encontrado.'); - const label = String(input.label || '').trim(); - if (!label) throw new BadRequestException('Informe o nome do publico.'); - - const result = await this.database.query( - ` - INSERT INTO bot_triage_audiences (flow_id, label, keywords, sort_order, active, updated_at) - VALUES ($1, $2, $3, $4, TRUE, CURRENT_TIMESTAMP) - RETURNING * - `, - [flow.id, label, this.cleanOptional(input.keywords), input.sortOrder || flow.audiences.length + 1], - ); - - return result.rows[0]; - } - - async updateAudience(id: number, input: { label?: string; keywords?: string; sortOrder?: number; active?: boolean }) { - const result = await this.database.query( - ` - UPDATE bot_triage_audiences - SET - label = COALESCE($2, label), - keywords = COALESCE($3, keywords), - sort_order = COALESCE($4, sort_order), - active = COALESCE($5, active), - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING * - `, - [ - id, - this.cleanOptional(input.label), - this.cleanOptional(input.keywords), - input.sortOrder ? Number(input.sortOrder) : null, - typeof input.active === 'boolean' ? input.active : null, - ], - ); - - return result.rows[0] || null; - } - - async createIntent(input: { - audienceId: number; - label: string; - areaId: number; - keywords?: string; - responseMessage?: string; - resolutionQuestion?: string; - escalationMessage?: string; - sortOrder?: number; - }) { - const audienceId = Number(input.audienceId); - const areaId = Number(input.areaId); - const label = String(input.label || '').trim(); - - if (!audienceId || !areaId || !label) { - throw new BadRequestException('Informe publico, especialidade e nome da intencao.'); - } - - const count = await this.database.query<{ total: string }>( - 'SELECT COUNT(*)::TEXT AS total FROM bot_triage_intents WHERE audience_id = $1', - [audienceId], - ); - - const result = await this.database.query( - ` - INSERT INTO bot_triage_intents ( - audience_id, label, area_id, keywords, response_message, resolution_question, - escalation_message, sort_order, active, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 'Certo, vou encaminhar seu atendimento para o time responsável.'), $8, TRUE, CURRENT_TIMESTAMP) - RETURNING * - `, - [ - audienceId, - label, - areaId, - this.cleanOptional(input.keywords), - this.cleanOptional(input.responseMessage), - this.cleanOptional(input.resolutionQuestion), - this.cleanOptional(input.escalationMessage), - input.sortOrder || Number(count.rows[0]?.total || 0) + 1, - ], - ); - - return result.rows[0]; - } - - async updateIntent(id: number, input: { - label?: string; - areaId?: number; - keywords?: string; - responseMessage?: string; - resolutionQuestion?: string; - escalationMessage?: string; - sortOrder?: number; - active?: boolean; - }) { - const result = await this.database.query( - ` - UPDATE bot_triage_intents - SET - label = COALESCE($2, label), - area_id = COALESCE($3, area_id), - keywords = COALESCE($4, keywords), - response_message = COALESCE($5, response_message), - resolution_question = COALESCE($6, resolution_question), - escalation_message = COALESCE($7, escalation_message), - sort_order = COALESCE($8, sort_order), - active = COALESCE($9, active), - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING * - `, - [ - id, - this.cleanOptional(input.label), - input.areaId ? Number(input.areaId) : null, - this.cleanOptional(input.keywords), - this.cleanOptional(input.responseMessage), - this.cleanOptional(input.resolutionQuestion), - this.cleanOptional(input.escalationMessage), - input.sortOrder ? Number(input.sortOrder) : null, - typeof input.active === 'boolean' ? input.active : null, - ], - ); - - return result.rows[0] || null; - } - - private async ensureDraftBotFlow() { - const existing = await this.database.query( - ` - SELECT * - FROM bot_flow_versions - WHERE status = 'draft' - ORDER BY id ASC - LIMIT 1 - `, - ).catch(() => ({ rows: [] })); - - let draft = existing.rows[0]; - if (!draft) { - const inserted = await this.database.query( - ` - INSERT INTO bot_flow_versions (name, status, version_number, updated_at) - VALUES ('Fluxo RH Sothis', 'draft', 0, CURRENT_TIMESTAMP) - RETURNING * - `, - ); - draft = inserted.rows[0]; - } - - const root = await this.database.query( - ` - SELECT * - FROM bot_flow_nodes - WHERE version_id = $1 - AND node_type = 'greeting' - ORDER BY id ASC - LIMIT 1 - `, - [draft.id], - ); - - if (!root.rows[0]) { - const insertedRoot = await this.database.query( - ` - INSERT INTO bot_flow_nodes ( - version_id, parent_id, node_type, title, message_text, fallback_message, - fallback_attempts, sort_order, updated_at - ) - VALUES ( - $1, - NULL, - 'greeting', - 'Saudacao inicial', - 'Ola! Sou o Agente Virtual Sothis. Vou te direcionar para o atendimento correto de RH. Digite o numero da opcao que melhor descreve voce:\n\n1 - Sou colaborador ativo\n2 - Sou ex-colaborador\n3 - Sou candidato a uma vaga', - 'Nao consegui identificar seu perfil. Digite 1 para colaborador ativo, 2 para ex-colaborador ou 3 para candidato.', - 2, - 1, - CURRENT_TIMESTAMP - ) - RETURNING * - `, - [draft.id], - ); - - await this.database.query( - 'UPDATE bot_flow_versions SET root_node_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *', - [draft.id, insertedRoot.rows[0].id], - ); - draft.root_node_id = insertedRoot.rows[0].id; - } else if (!draft.root_node_id) { - await this.database.query( - 'UPDATE bot_flow_versions SET root_node_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1', - [draft.id, root.rows[0].id], - ); - draft.root_node_id = root.rows[0].id; - } - - return draft; - } - - private async getFlowNodes(versionId: number) { - const result = await this.database.query( - ` - SELECT - node.*, - area.nome AS area_nome, - fallback_area.nome AS fallback_area_nome - FROM bot_flow_nodes node - LEFT JOIN areas area ON area.id = node.area_id - LEFT JOIN areas fallback_area ON fallback_area.id = node.fallback_area_id - WHERE node.version_id = $1 - ORDER BY node.parent_id NULLS FIRST, node.sort_order ASC, node.id ASC - `, - [versionId], - ); - - return result.rows; - } - - private buildNodeTree(nodes: any[], rootNodeId?: number | null) { - const byId = new Map(); - nodes.forEach((node) => byId.set(Number(node.id), { ...node, children: [] })); - - byId.forEach((node) => { - if (node.parent_id && byId.has(Number(node.parent_id))) { - byId.get(Number(node.parent_id)).children.push(node); - } - }); - - const root = rootNodeId ? byId.get(Number(rootNodeId)) : null; - return root || nodes.find((node) => node.node_type === 'greeting') || null; - } - - private async getDraftNode(id: number, versionId: number) { - const result = await this.database.query( - 'SELECT * FROM bot_flow_nodes WHERE id = $1 AND version_id = $2 LIMIT 1', - [id, versionId], - ); - - return result.rows[0] || null; - } - - private validateFlow(nodes: any[], rootNodeId?: number | null) { - const root = nodes.find((node) => Number(node.id) === Number(rootNodeId)); - if (!root || root.node_type !== 'greeting') { - throw new BadRequestException('O fluxo precisa ter um no raiz de saudacao.'); - } - - const childrenByParent = new Map(); - nodes.forEach((node) => { - if (!node.parent_id) return; - const parentId = Number(node.parent_id); - childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), node]); - }); - - nodes.forEach((node) => { - const children = childrenByParent.get(Number(node.id)) || []; - if (['agent', 'close'].includes(node.node_type) && children.length) { - throw new BadRequestException(`O no "${node.title}" e terminal e nao pode ter filhos.`); - } - - if (!['agent', 'close'].includes(node.node_type) && !children.length) { - throw new BadRequestException(`O no "${node.title}" precisa ter ao menos um filho antes de publicar.`); - } - - if (node.node_type === 'agent' && !node.area_id) { - throw new BadRequestException(`O no "${node.title}" precisa de uma especialidade.`); - } - - if (node.parent_id && !this.parseKeywords(node.keywords).length) { - throw new BadRequestException(`O no "${node.title}" precisa de pelo menos uma keyword.`); - } - - this.assertSiblingKeywordsAreUnique(children); - }); - } - - private assertSiblingKeywordsAreUnique(nodes: any[]) { - const seen = new Map(); - nodes.forEach((node) => { - const ownKeywords = new Set(this.parseKeywords(node.keywords)); - ownKeywords.forEach((keyword) => { - const previous = seen.get(keyword); - if (previous) { - throw new BadRequestException(`Keyword "${keyword}" duplicada entre "${previous}" e "${node.title}".`); - } - seen.set(keyword, node.title); - }); - }); - } - - private async assertNoKeywordConflict(parentId: number, keywords: string | null, ignoreNodeId: number | null) { - const incoming = Array.from(new Set(this.parseKeywords(keywords))); - if (!incoming.length) return; - - const siblings = await this.database.query( - ` - SELECT id, title, keywords - FROM bot_flow_nodes - WHERE parent_id = $1 - AND ($2::int IS NULL OR id <> $2) - `, - [parentId, ignoreNodeId], - ); - - const used = new Map(); - siblings.rows.forEach((node) => { - this.parseKeywords(node.keywords).forEach((keyword) => used.set(keyword, node.title)); - }); - - const conflict = incoming.find((keyword) => used.has(keyword)); - if (conflict) { - throw new BadRequestException(`Keyword "${conflict}" ja esta em uso no no "${used.get(conflict)}".`); - } - } - - private parseKeywords(value?: string | null) { - return String(value || '') - .split(',') - .map((keyword) => this.normalize(keyword).trim()) - .filter(Boolean); - } - - private normalize(value: string) { - return String(value || '') - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase(); - } - - private cleanOptional(value?: string | null) { - const text = String(value || '').trim(); - return text || null; - } - - async listRoutingKeywords(areaId?: number | null) { - const params: unknown[] = []; - const where = areaId ? 'WHERE ark.area_id = $1' : ''; - if (areaId) params.push(areaId); - - const result = await this.database.query( - ` - SELECT - ark.id, - ark.area_id, - a.nome AS area_nome, - ark.keyword, - ark.active, - ark.created_at, - ark.updated_at - FROM area_routing_keywords ark - INNER JOIN areas a ON a.id = ark.area_id - ${where} - ORDER BY a.nome ASC, ark.keyword ASC - `, - params, - ); - - return result.rows; - } - - async createRoutingKeyword(input: { areaId: number; keyword: string }) { - const areaId = Number(input.areaId); - const keyword = String(input.keyword || '').trim().toLowerCase(); - - if (!Number.isFinite(areaId) || areaId <= 0) { - throw new BadRequestException('Especialidade invalida para palavra-chave.'); - } - - if (!keyword) { - throw new BadRequestException('Informe uma palavra-chave.'); - } - - const result = await this.database.query( - ` - INSERT INTO area_routing_keywords (area_id, keyword, active, created_at, updated_at) - VALUES ($1, $2, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - ON CONFLICT (area_id, keyword) DO UPDATE SET - active = TRUE, - updated_at = CURRENT_TIMESTAMP - RETURNING * - `, - [areaId, keyword], - ); - - return result.rows[0]; - } - - async updateRoutingKeyword(id: number, input: { keyword?: string; active?: boolean }) { - const keyword = typeof input.keyword === 'string' ? input.keyword.trim().toLowerCase() : null; - const active = typeof input.active === 'boolean' ? input.active : null; - - const result = await this.database.query( - ` - UPDATE area_routing_keywords - SET - keyword = COALESCE($2, keyword), - active = COALESCE($3, active), - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING * - `, - [id, keyword || null, active], - ); - - return result.rows[0] || null; - } - - async deleteRoutingKeyword(id: number) { - await this.database.query('DELETE FROM area_routing_keywords WHERE id = $1', [id]); - return { success: true }; - } -} diff --git a/src/modules/admin/knowledge-base.controller.ts b/src/modules/knowledge-base/knowledge-base.controller.ts similarity index 100% rename from src/modules/admin/knowledge-base.controller.ts rename to src/modules/knowledge-base/knowledge-base.controller.ts diff --git a/src/modules/knowledge-base/knowledge-base.module.ts b/src/modules/knowledge-base/knowledge-base.module.ts new file mode 100644 index 0000000..dd73741 --- /dev/null +++ b/src/modules/knowledge-base/knowledge-base.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { KnowledgeBaseController } from './knowledge-base.controller'; +import { KnowledgeBaseRepository } from './repositories/knowledge-base.repository'; +import { KnowledgeBaseService } from './knowledge-base.service'; + +@Module({ + controllers: [KnowledgeBaseController], + providers: [KnowledgeBaseService, KnowledgeBaseRepository], + exports: [KnowledgeBaseService], +}) +export class KnowledgeBaseModule {} diff --git a/src/modules/knowledge-base/knowledge-base.service.ts b/src/modules/knowledge-base/knowledge-base.service.ts new file mode 100644 index 0000000..54b3d1c --- /dev/null +++ b/src/modules/knowledge-base/knowledge-base.service.ts @@ -0,0 +1,504 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { KnowledgeBaseRepository } from './repositories/knowledge-base.repository'; + +@Injectable() +export class KnowledgeBaseService { + constructor(private readonly knowledgeBaseRepository: KnowledgeBaseRepository) {} + + async getBotFlow() { + const draft = await this.ensureDraftBotFlow(); + const nodes = await this.getFlowNodes(draft.id); + const latestPublished = await this.knowledgeBaseRepository.getLatestPublishedVersion(); + + return { + ...draft, + nodes, + tree: this.buildNodeTree(nodes, draft.root_node_id), + latestPublished: latestPublished.rows[0] || null, + }; + } + + async listBotFlowVersions() { + const result = await this.knowledgeBaseRepository.listPublishedVersions(); + + 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.knowledgeBaseRepository.countChildNodes(parentId); + + const title = + this.cleanOptional(input.title) || + (nodeType === 'agent' ? 'Enviar para agente' : nodeType === 'close' ? 'Encerrar pelo bot' : 'Pergunta'); + + const result = await this.knowledgeBaseRepository.insertBotFlowNode({ + versionId: draft.id, + parentId, + nodeType, + title, + messageText: this.cleanOptional(input.messageText), + keywords: this.cleanOptional(input.keywords), + fallbackMessage: this.cleanOptional(input.fallbackMessage), + fallbackAttempts: input.fallbackAttempts ? Number(input.fallbackAttempts) : null, + fallbackAreaId: input.fallbackAreaId || null, + areaId: input.areaId || null, + sortOrder: 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.knowledgeBaseRepository.updateBotFlowNode(id, draft.id, { + title: this.cleanOptional(input.title), + messageText: this.cleanOptional(input.messageText), + keywords: this.cleanOptional(input.keywords), + fallbackMessage: this.cleanOptional(input.fallbackMessage), + fallbackAttempts: input.fallbackAttempts ? Number(input.fallbackAttempts) : null, + fallbackAreaId: input.fallbackAreaId || null, + areaId: input.areaId || null, + sortOrder: input.sortOrder ? Number(input.sortOrder) : null, + }); + + 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.knowledgeBaseRepository.deleteBotFlowNode(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.knowledgeBaseRepository.getNextPublishedVersionNumber(); + + const published = await this.knowledgeBaseRepository.insertPublishedVersion( + draft.name, + nextVersion.rows[0].next_version, + { nodes }, + ); + + const publishedVersion = published.rows[0]; + const idMap = new Map(); + const childrenByParent = new Map(); + nodes.forEach((node) => { + if (!node.parent_id) return; + const parentId = Number(node.parent_id); + childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), node]); + }); + + const rootNode = nodes.find((node) => Number(node.id) === Number(draft.root_node_id)); + if (!rootNode) { + throw new BadRequestException('O fluxo precisa ter um no raiz de saudacao.'); + } + + const copyNode = async (node: any, parentId: number | null): Promise => { + const copied = await this.knowledgeBaseRepository.copyBotFlowNode({ + versionId: publishedVersion.id, + parentId, + node, + }); + 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.knowledgeBaseRepository.updateFlowRootNode(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.knowledgeBaseRepository.getSupportArea(); + 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.knowledgeBaseRepository.countChildNodes(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.knowledgeBaseRepository.getActiveTriageFlow(); + + const flow = flowResult.rows[0]; + if (!flow) return null; + + const audienceResult = await this.knowledgeBaseRepository.listTriageAudiences(flow.id); + + const audienceIds = audienceResult.rows.map((audience) => audience.id); + const intentResult = await this.knowledgeBaseRepository.listTriageIntents(audienceIds); + + 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.knowledgeBaseRepository.updateTriageFlow(flow.id, { + greetingMessage: this.cleanOptional(input.greetingMessage), + audienceQuestion: this.cleanOptional(input.audienceQuestion), + intentQuestionTemplate: this.cleanOptional(input.intentQuestionTemplate), + resolutionQuestion: this.cleanOptional(input.resolutionQuestion), + fallbackMessage: this.cleanOptional(input.fallbackMessage), + fallbackAreaId: input.fallbackAreaId || null, + maxAttempts: 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.knowledgeBaseRepository.insertAudience({ + flowId: flow.id, + label, + keywords: this.cleanOptional(input.keywords), + sortOrder: 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.knowledgeBaseRepository.updateAudience(id, { + label: this.cleanOptional(input.label), + keywords: this.cleanOptional(input.keywords), + sortOrder: input.sortOrder ? Number(input.sortOrder) : null, + active: 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.knowledgeBaseRepository.countAudienceIntents(audienceId); + + const result = await this.knowledgeBaseRepository.insertIntent({ + audienceId, + label, + areaId, + keywords: this.cleanOptional(input.keywords), + responseMessage: this.cleanOptional(input.responseMessage), + resolutionQuestion: this.cleanOptional(input.resolutionQuestion), + escalationMessage: this.cleanOptional(input.escalationMessage), + sortOrder: 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.knowledgeBaseRepository.updateIntent(id, { + label: this.cleanOptional(input.label), + areaId: input.areaId ? Number(input.areaId) : null, + keywords: this.cleanOptional(input.keywords), + responseMessage: this.cleanOptional(input.responseMessage), + resolutionQuestion: this.cleanOptional(input.resolutionQuestion), + escalationMessage: this.cleanOptional(input.escalationMessage), + sortOrder: input.sortOrder ? Number(input.sortOrder) : null, + active: typeof input.active === 'boolean' ? input.active : null, + }); + + return result.rows[0] || null; + } + + private async ensureDraftBotFlow() { + const existing = await this.knowledgeBaseRepository.getDraftFlow().catch(() => ({ rows: [] })); + + let draft = existing.rows[0]; + if (!draft) { + const inserted = await this.knowledgeBaseRepository.insertDraftFlow(); + draft = inserted.rows[0]; + } + + const root = await this.knowledgeBaseRepository.getGreetingNode(draft.id); + + if (!root.rows[0]) { + const insertedRoot = await this.knowledgeBaseRepository.insertGreetingNode(draft.id); + + await this.knowledgeBaseRepository.updateFlowRootNode(draft.id, insertedRoot.rows[0].id); + draft.root_node_id = insertedRoot.rows[0].id; + } else if (!draft.root_node_id) { + await this.knowledgeBaseRepository.updateFlowRootNode(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.knowledgeBaseRepository.getFlowNodes(versionId); + + return result.rows; + } + + private buildNodeTree(nodes: any[], rootNodeId?: number | null) { + const byId = new Map(); + nodes.forEach((node) => byId.set(Number(node.id), { ...node, children: [] })); + + byId.forEach((node) => { + if (node.parent_id && byId.has(Number(node.parent_id))) { + byId.get(Number(node.parent_id)).children.push(node); + } + }); + + const root = rootNodeId ? byId.get(Number(rootNodeId)) : null; + return root || nodes.find((node) => node.node_type === 'greeting') || null; + } + + private async getDraftNode(id: number, versionId: number) { + const result = await this.knowledgeBaseRepository.getDraftNode(id, versionId); + + return result.rows[0] || null; + } + + private validateFlow(nodes: any[], rootNodeId?: number | null) { + const root = nodes.find((node) => Number(node.id) === Number(rootNodeId)); + if (!root || root.node_type !== 'greeting') { + throw new BadRequestException('O fluxo precisa ter um no raiz de saudacao.'); + } + + const childrenByParent = new Map(); + nodes.forEach((node) => { + if (!node.parent_id) return; + const parentId = Number(node.parent_id); + childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), node]); + }); + + nodes.forEach((node) => { + const children = childrenByParent.get(Number(node.id)) || []; + if (['agent', 'close'].includes(node.node_type) && children.length) { + throw new BadRequestException(`O no "${node.title}" e terminal e nao pode ter filhos.`); + } + + if (!['agent', 'close'].includes(node.node_type) && !children.length) { + throw new BadRequestException(`O no "${node.title}" precisa ter ao menos um filho antes de publicar.`); + } + + if (node.node_type === 'agent' && !node.area_id) { + throw new BadRequestException(`O no "${node.title}" precisa de uma especialidade.`); + } + + if (node.parent_id && !this.parseKeywords(node.keywords).length) { + throw new BadRequestException(`O no "${node.title}" precisa de pelo menos uma keyword.`); + } + + this.assertSiblingKeywordsAreUnique(children); + }); + } + + private assertSiblingKeywordsAreUnique(nodes: any[]) { + const seen = new Map(); + nodes.forEach((node) => { + const ownKeywords = new Set(this.parseKeywords(node.keywords)); + ownKeywords.forEach((keyword) => { + const previous = seen.get(keyword); + if (previous) { + throw new BadRequestException(`Keyword "${keyword}" duplicada entre "${previous}" e "${node.title}".`); + } + seen.set(keyword, node.title); + }); + }); + } + + private async assertNoKeywordConflict(parentId: number, keywords: string | null, ignoreNodeId: number | null) { + const incoming = Array.from(new Set(this.parseKeywords(keywords))); + if (!incoming.length) return; + + const siblings = await this.knowledgeBaseRepository.listSiblingKeywords(parentId, ignoreNodeId); + + const used = new Map(); + siblings.rows.forEach((node) => { + this.parseKeywords(node.keywords).forEach((keyword) => used.set(keyword, node.title)); + }); + + const conflict = incoming.find((keyword) => used.has(keyword)); + if (conflict) { + throw new BadRequestException(`Keyword "${conflict}" ja esta em uso no no "${used.get(conflict)}".`); + } + } + + private parseKeywords(value?: string | null) { + return String(value || '') + .split(',') + .map((keyword) => this.normalize(keyword).trim()) + .filter(Boolean); + } + + private normalize(value: string) { + return String(value || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase(); + } + + private cleanOptional(value?: string | null) { + const text = String(value || '').trim(); + return text || null; + } + + async listRoutingKeywords(areaId?: number | null) { + const result = await this.knowledgeBaseRepository.listRoutingKeywords(areaId); + + 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.knowledgeBaseRepository.upsertRoutingKeyword(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.knowledgeBaseRepository.updateRoutingKeyword(id, keyword || null, active); + + return result.rows[0] || null; + } + + async deleteRoutingKeyword(id: number) { + await this.knowledgeBaseRepository.deleteRoutingKeyword(id); + return { success: true }; + } +} diff --git a/src/modules/knowledge-base/repositories/knowledge-base.repository.ts b/src/modules/knowledge-base/repositories/knowledge-base.repository.ts new file mode 100644 index 0000000..5ef47fb --- /dev/null +++ b/src/modules/knowledge-base/repositories/knowledge-base.repository.ts @@ -0,0 +1,529 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../../../infra/database/database.service'; + +@Injectable() +export class KnowledgeBaseRepository { + constructor(private readonly database: DatabaseService) {} + + getLatestPublishedVersion() { + return 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 + `, + ); + } + + listPublishedVersions() { + return 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 + `, + ); + } + + countChildNodes(parentId: number) { + return this.database.query<{ total: string }>( + 'SELECT COUNT(*)::TEXT AS total FROM bot_flow_nodes WHERE parent_id = $1', + [parentId], + ); + } + + insertBotFlowNode(input: { + versionId: number; + parentId: number | null; + nodeType: string; + title: string; + messageText?: string | null; + keywords?: string | null; + fallbackMessage?: string | null; + fallbackAttempts?: number | null; + fallbackAreaId?: number | null; + areaId?: number | null; + sortOrder: number; + }) { + return 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 * + `, + [ + input.versionId, + input.parentId, + input.nodeType, + input.title, + input.messageText || null, + input.keywords || null, + input.fallbackMessage || null, + input.fallbackAttempts || null, + input.fallbackAreaId || null, + input.areaId || null, + input.sortOrder, + ], + ); + } + + updateBotFlowNode(id: number, versionId: number, input: { + title?: string | null; + messageText?: string | null; + keywords?: string | null; + fallbackMessage?: string | null; + fallbackAttempts?: number | null; + fallbackAreaId?: number | null; + areaId?: number | null; + sortOrder?: number | null; + }) { + return 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, + input.title || null, + input.messageText || null, + input.keywords || null, + input.fallbackMessage || null, + input.fallbackAttempts || null, + input.fallbackAreaId || null, + input.areaId || null, + input.sortOrder || null, + versionId, + ], + ); + } + + deleteBotFlowNode(id: number, versionId: number) { + return this.database.query('DELETE FROM bot_flow_nodes WHERE id = $1 AND version_id = $2', [id, versionId]); + } + + getNextPublishedVersionNumber() { + return this.database.query<{ next_version: number }>( + ` + SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version + FROM bot_flow_versions + WHERE status = 'published' + `, + ); + } + + insertPublishedVersion(name: string, versionNumber: number, snapshot: unknown) { + return 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 * + `, + [name, versionNumber, JSON.stringify(snapshot)], + ); + } + + copyBotFlowNode(input: { + versionId: number; + parentId: number | null; + node: any; + }) { + const node = input.node; + + return 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 * + `, + [ + input.versionId, + input.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, + ], + ); + } + + updateFlowRootNode(versionId: number, rootNodeId?: number | null) { + return this.database.query( + 'UPDATE bot_flow_versions SET root_node_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1', + [versionId, rootNodeId || null], + ); + } + + getSupportArea() { + return this.database.query<{ id: number }>( + `SELECT id FROM areas WHERE nome = 'Suporte' ORDER BY id ASC LIMIT 1`, + ); + } + + getActiveTriageFlow() { + return this.database.query(` + SELECT * + FROM bot_triage_flows + WHERE active = TRUE + ORDER BY id ASC + LIMIT 1 + `); + } + + listTriageAudiences(flowId: number) { + return this.database.query( + ` + SELECT * + FROM bot_triage_audiences + WHERE flow_id = $1 + ORDER BY sort_order ASC, id ASC + `, + [flowId], + ); + } + + listTriageIntents(audienceIds: number[]) { + if (!audienceIds.length) { + return Promise.resolve({ rows: [] }); + } + + return this.database.query( + ` + SELECT + bti.*, + a.nome AS area_nome + FROM bot_triage_intents bti + INNER JOIN areas a ON a.id = bti.area_id + WHERE bti.audience_id = ANY($1::int[]) + ORDER BY bti.sort_order ASC, bti.id ASC + `, + [audienceIds], + ); + } + + updateTriageFlow(id: number, input: { + greetingMessage?: string | null; + audienceQuestion?: string | null; + intentQuestionTemplate?: string | null; + resolutionQuestion?: string | null; + fallbackMessage?: string | null; + fallbackAreaId?: number | null; + maxAttempts?: number | null; + }) { + return 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 * + `, + [ + id, + input.greetingMessage || null, + input.audienceQuestion || null, + input.intentQuestionTemplate || null, + input.resolutionQuestion || null, + input.fallbackMessage || null, + input.fallbackAreaId || null, + input.maxAttempts || null, + ], + ); + } + + insertAudience(input: { flowId: number; label: string; keywords?: string | null; sortOrder: number }) { + return 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 * + `, + [input.flowId, input.label, input.keywords || null, input.sortOrder], + ); + } + + updateAudience(id: number, input: { label?: string | null; keywords?: string | null; sortOrder?: number | null; active?: boolean | null }) { + return 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, input.label || null, input.keywords || null, input.sortOrder || null, input.active], + ); + } + + countAudienceIntents(audienceId: number) { + return this.database.query<{ total: string }>( + 'SELECT COUNT(*)::TEXT AS total FROM bot_triage_intents WHERE audience_id = $1', + [audienceId], + ); + } + + insertIntent(input: { + audienceId: number; + label: string; + areaId: number; + keywords?: string | null; + responseMessage?: string | null; + resolutionQuestion?: string | null; + escalationMessage?: string | null; + sortOrder: number; + }) { + return 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 o time responsavel.'), $8, TRUE, CURRENT_TIMESTAMP) + RETURNING * + `, + [ + input.audienceId, + input.label, + input.areaId, + input.keywords || null, + input.responseMessage || null, + input.resolutionQuestion || null, + input.escalationMessage || null, + input.sortOrder, + ], + ); + } + + updateIntent(id: number, input: { + label?: string | null; + areaId?: number | null; + keywords?: string | null; + responseMessage?: string | null; + resolutionQuestion?: string | null; + escalationMessage?: string | null; + sortOrder?: number | null; + active?: boolean | null; + }) { + return 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, + input.label || null, + input.areaId || null, + input.keywords || null, + input.responseMessage || null, + input.resolutionQuestion || null, + input.escalationMessage || null, + input.sortOrder || null, + input.active, + ], + ); + } + + getDraftFlow() { + return this.database.query( + ` + SELECT * + FROM bot_flow_versions + WHERE status = 'draft' + ORDER BY id ASC + LIMIT 1 + `, + ); + } + + insertDraftFlow() { + return this.database.query( + ` + INSERT INTO bot_flow_versions (name, status, version_number, updated_at) + VALUES ('Fluxo RH Sothis', 'draft', 0, CURRENT_TIMESTAMP) + RETURNING * + `, + ); + } + + getGreetingNode(versionId: number) { + return this.database.query( + ` + SELECT * + FROM bot_flow_nodes + WHERE version_id = $1 + AND node_type = 'greeting' + ORDER BY id ASC + LIMIT 1 + `, + [versionId], + ); + } + + insertGreetingNode(versionId: number) { + return 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 * + `, + [versionId], + ); + } + + getFlowNodes(versionId: number) { + return this.database.query( + ` + SELECT + node.*, + area.nome AS area_nome, + fallback_area.nome AS fallback_area_nome + FROM bot_flow_nodes node + LEFT JOIN areas area ON area.id = node.area_id + LEFT JOIN areas fallback_area ON fallback_area.id = node.fallback_area_id + WHERE node.version_id = $1 + ORDER BY node.parent_id NULLS FIRST, node.sort_order ASC, node.id ASC + `, + [versionId], + ); + } + + getDraftNode(id: number, versionId: number) { + return this.database.query( + 'SELECT * FROM bot_flow_nodes WHERE id = $1 AND version_id = $2 LIMIT 1', + [id, versionId], + ); + } + + listSiblingKeywords(parentId: number, ignoreNodeId: number | null) { + return 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], + ); + } + + listRoutingKeywords(areaId?: number | null) { + const params: unknown[] = []; + const where = areaId ? 'WHERE ark.area_id = $1' : ''; + if (areaId) params.push(areaId); + + return 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, + ); + } + + upsertRoutingKeyword(areaId: number, keyword: string) { + return 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], + ); + } + + updateRoutingKeyword(id: number, keyword?: string | null, active?: boolean | null) { + return 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], + ); + } + + deleteRoutingKeyword(id: number) { + return this.database.query('DELETE FROM area_routing_keywords WHERE id = $1', [id]); + } +}