FEAT: Reestruturado módulo de Knowledge Base
Some checks failed
Deploy Dev / deploy (push) Has been cancelled
Some checks failed
Deploy Dev / deploy (push) Has been cancelled
- 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
This commit is contained in:
parent
df51645e72
commit
377b90d37a
@ -5,6 +5,7 @@ import { AppController } from './app.controller';
|
|||||||
import { DatabaseModule } from './infra/database/database.module';
|
import { DatabaseModule } from './infra/database/database.module';
|
||||||
import { AdminModule } from './modules/admin/admin.module';
|
import { AdminModule } from './modules/admin/admin.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module';
|
||||||
import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
|
import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -18,6 +19,7 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
|
|||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
KnowledgeBaseModule,
|
||||||
WhatsappModule,
|
WhatsappModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@ -7,8 +7,6 @@ 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';
|
|
||||||
import { AdminAccessRepository } from './repositories/admin-access.repository';
|
import { AdminAccessRepository } from './repositories/admin-access.repository';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -17,7 +15,6 @@ import { AdminAccessRepository } from './repositories/admin-access.repository';
|
|||||||
AgentNotesController,
|
AgentNotesController,
|
||||||
AgentPresenceController,
|
AgentPresenceController,
|
||||||
CustomerContactsController,
|
CustomerContactsController,
|
||||||
KnowledgeBaseController,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminAccessService,
|
AdminAccessService,
|
||||||
@ -25,7 +22,6 @@ import { AdminAccessRepository } from './repositories/admin-access.repository';
|
|||||||
AgentNotesService,
|
AgentNotesService,
|
||||||
AgentPresenceService,
|
AgentPresenceService,
|
||||||
CustomerContactsService,
|
CustomerContactsService,
|
||||||
KnowledgeBaseService,
|
|
||||||
],
|
],
|
||||||
exports: [AgentPresenceService],
|
exports: [AgentPresenceService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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<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 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<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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
src/modules/knowledge-base/knowledge-base.module.ts
Normal file
11
src/modules/knowledge-base/knowledge-base.module.ts
Normal file
@ -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 {}
|
||||||
504
src/modules/knowledge-base/knowledge-base.service.ts
Normal file
504
src/modules/knowledge-base/knowledge-base.service.ts
Normal file
@ -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<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.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<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.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<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.knowledgeBaseRepository.listSiblingKeywords(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 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user