Compare commits

..

2 Commits

Author SHA1 Message Date
2fd19e9bae FEAT: Roteamento para atendentes melhorado
All checks were successful
Deploy Dev / deploy (push) Successful in 3s
2026-05-26 12:12:56 -03:00
d495698c02 FEAT: Atualizado agenda de contatos com canais extras 2026-05-26 11:35:01 -03:00
4 changed files with 162 additions and 25 deletions

View File

@ -1,6 +1,17 @@
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { CustomerContactsService } from './customer-contacts.service';
interface SaveContactBody {
phone?: string | null;
whatsappPhone?: string | null;
callSmsPhone?: string | null;
email?: string | null;
name?: string | null;
company?: string | null;
note?: string | null;
userId?: number | null;
}
@Controller('contacts')
export class CustomerContactsController {
constructor(private readonly customerContactsService: CustomerContactsService) {}
@ -18,11 +29,13 @@ export class CustomerContactsController {
@Put(':chatId')
saveContact(
@Param('chatId') chatId: string,
@Body() body: { phone?: string | null; name?: string | null; company?: string | null; note?: string | null; userId?: number | null },
@Body() body: SaveContactBody,
) {
return this.customerContactsService.saveContact({
chatId: decodeURIComponent(chatId),
phone: body.phone,
phone: body.whatsappPhone || body.phone,
callSmsPhone: body.callSmsPhone,
email: body.email,
name: body.name,
company: body.company,
note: body.note,

View File

@ -4,6 +4,8 @@ import { DatabaseService } from '../../infra/database/database.service';
interface SaveContactInput {
chatId: string;
phone?: string | null;
callSmsPhone?: string | null;
email?: string | null;
name?: string | null;
company?: string | null;
note?: string | null;
@ -19,6 +21,8 @@ export class CustomerContactsService implements OnModuleInit {
CREATE TABLE IF NOT EXISTS agenda_contatos (
chat_id VARCHAR(255) PRIMARY KEY,
phone VARCHAR(80) NOT NULL,
call_sms_phone VARCHAR(80),
email VARCHAR(255),
name VARCHAR(255),
company VARCHAR(255),
note TEXT,
@ -27,12 +31,17 @@ export class CustomerContactsService implements OnModuleInit {
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await this.database.query(`
ALTER TABLE agenda_contatos
ADD COLUMN IF NOT EXISTS call_sms_phone VARCHAR(80),
ADD COLUMN IF NOT EXISTS email VARCHAR(255);
`);
}
async getContact(chatId: string) {
const result = await this.database.query(
`
SELECT chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
SELECT chat_id, phone, call_sms_phone, email, name, company, note, updated_by_user_id, created_at, updated_at
FROM agenda_contatos
WHERE chat_id = $1
LIMIT 1
@ -46,10 +55,9 @@ export class CustomerContactsService implements OnModuleInit {
async listContacts() {
const result = await this.database.query(
`
SELECT chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
SELECT chat_id, phone, call_sms_phone, email, name, company, note, updated_by_user_id, created_at, updated_at
FROM agenda_contatos
ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST
LIMIT 80
`,
);
@ -60,20 +68,25 @@ export class CustomerContactsService implements OnModuleInit {
const result = await this.database.query(
`
INSERT INTO agenda_contatos (
chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
chat_id, phone, call_sms_phone, email, name, company, note, updated_by_user_id, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (chat_id) DO UPDATE SET
phone = EXCLUDED.phone,
call_sms_phone = EXCLUDED.call_sms_phone,
email = EXCLUDED.email,
name = EXCLUDED.name,
company = EXCLUDED.company,
note = EXCLUDED.note,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = CURRENT_TIMESTAMP
RETURNING chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
RETURNING chat_id, phone, call_sms_phone, email, name, company, note, updated_by_user_id, created_at, updated_at
`,
[
input.chatId,
this.normalizePhone(input.phone, input.chatId),
this.normalizeOptionalPhone(input.callSmsPhone),
this.normalizeEmail(input.email),
this.normalizeText(input.name),
this.normalizeText(input.company),
this.normalizeText(input.note),
@ -88,6 +101,8 @@ export class CustomerContactsService implements OnModuleInit {
return {
chat_id: chatId,
phone: this.normalizePhone(null, chatId),
call_sms_phone: null,
email: null,
name: null,
company: null,
note: null,
@ -103,6 +118,16 @@ export class CustomerContactsService implements OnModuleInit {
return chatId.endsWith('@lid') ? '' : String(chatId || '').split('@')[0];
}
private normalizeOptionalPhone(phone: string | null | undefined) {
const cleanPhone = String(phone || '').replace(/\D/g, '');
return cleanPhone || null;
}
private normalizeEmail(value?: string | null) {
const email = String(value || '').trim().toLowerCase();
return email || null;
}
private normalizeText(value?: string | null) {
const text = String(value || '').trim();
return text || null;

View File

@ -31,7 +31,7 @@ export class KnowledgeBaseService implements OnModuleInit {
ALTER TABLE bot_triage_intents
ADD COLUMN IF NOT EXISTS response_message TEXT,
ADD COLUMN IF NOT EXISTS resolution_question TEXT,
ADD COLUMN IF NOT EXISTS escalation_message TEXT NOT NULL DEFAULT 'Certo, vou encaminhar seu atendimento para um especialista no assunto.';
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(`
@ -529,7 +529,7 @@ export class KnowledgeBaseService implements OnModuleInit {
audience_id, label, area_id, keywords, response_message, resolution_question,
escalation_message, sort_order, active, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 'Certo, vou encaminhar seu atendimento para um especialista no assunto.'), $8, TRUE, CURRENT_TIMESTAMP)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 'Certo, vou encaminhar seu atendimento para o time responsável.'), $8, TRUE, CURRENT_TIMESTAMP)
RETURNING *
`,
[

View File

@ -421,7 +421,7 @@ export class WhatsappAssignmentService implements OnModuleInit {
return builderFlowResult;
}
const configuredFlowResult = await this.routeConfiguredFlow(chatId, cleanMessage, current, messageId);
const configuredFlowResult = await this.routeConfiguredFlow(chatId, cleanMessage, current, messageId, variables);
if (configuredFlowResult) {
return configuredFlowResult;
}
@ -429,7 +429,14 @@ export class WhatsappAssignmentService implements OnModuleInit {
const detectedArea = await this.detectKnownArea(cleanMessage);
if (detectedArea) {
const assignment = await this.queueChat(chatId, detectedArea.id, `Roteado automaticamente pelo ${VIRTUAL_AGENT_NAME}`);
const assignment = await this.queueChat(
chatId,
detectedArea.id,
this.buildVirtualAgentTransferNote({
name: variables.nome,
intentLabel: detectedArea.name,
}),
);
await this.markBotRoute(chatId, messageId);
const hasPreviousTriage = current?.status === 'bot_triage';
const botMessage = hasPreviousTriage
@ -453,7 +460,14 @@ export class WhatsappAssignmentService implements OnModuleInit {
if (attempts >= 2) {
const supportArea = await this.getAreaByName('Suporte');
const assignment = await this.queueChat(chatId, supportArea.id, 'Roteado para suporte por falta de classificacao');
const assignment = await this.queueChat(
chatId,
supportArea.id,
this.buildVirtualAgentTransferNote({
name: variables.nome,
fallbackReason: 'não teve a solicitação classificada pelo Agente Virtual',
}),
);
await this.markBotRoute(chatId, messageId);
return {
@ -590,7 +604,11 @@ export class WhatsappAssignmentService implements OnModuleInit {
const assignment = await this.queueChat(
chatId,
areaId,
`Roteado pelo fluxo do ${VIRTUAL_AGENT_NAME}: ${matchedChild.title}`,
this.buildVirtualAgentTransferNote({
name: variables.nome,
audienceLabel: currentNode.title,
intentLabel: matchedChild.title,
}),
);
await this.markBotRoute(chatId, messageId);
@ -601,7 +619,7 @@ export class WhatsappAssignmentService implements OnModuleInit {
'Atendente virtual',
VIRTUAL_AGENT_NAME,
this.applyBotVariables(
matchedChild.message_text || `Certo, vou encaminhar seu atendimento para ${matchedChild.area_nome || 'a especialidade correta'}.`,
matchedChild.message_text || 'Certo, vou encaminhar seu atendimento para o time responsável.',
variables,
),
),
@ -628,7 +646,11 @@ export class WhatsappAssignmentService implements OnModuleInit {
const assignment = await this.queueChat(
chatId,
fallbackAreaId,
`Fallback do fluxo do ${VIRTUAL_AGENT_NAME}: ${currentNode.title}`,
this.buildVirtualAgentTransferNote({
name: variables.nome,
audienceLabel: currentNode.title,
fallbackReason: 'não teve a solicitação classificada pelo Agente Virtual',
}),
);
await this.markBotRoute(chatId, messageId);
@ -659,7 +681,13 @@ export class WhatsappAssignmentService implements OnModuleInit {
};
}
private async routeConfiguredFlow(chatId: string, message: string, current: any, messageId?: string) {
private async routeConfiguredFlow(
chatId: string,
message: string,
current: any,
messageId?: string,
variables: BotMessageVariables = {},
) {
const flow = await this.getActiveTriageFlow();
if (!flow || !flow.audiences.length) return null;
@ -694,7 +722,7 @@ export class WhatsappAssignmentService implements OnModuleInit {
}
if (current.triage_step === 'resolution' && current.triage_intent_id) {
return this.routeConfiguredResolution(chatId, message, flow, current, messageId);
return this.routeConfiguredResolution(chatId, message, flow, current, messageId, variables);
}
return this.routeConfiguredAudience(chatId, message, flow, current, messageId);
@ -778,14 +806,29 @@ export class WhatsappAssignmentService implements OnModuleInit {
};
}
private async routeConfiguredResolution(chatId: string, message: string, flow: TriageFlow, current: any, messageId?: string) {
const intent = flow.audiences
.flatMap((audience) => audience.intents)
.find((item) => Number(item.id) === Number(current.triage_intent_id));
private async routeConfiguredResolution(
chatId: string,
message: string,
flow: TriageFlow,
current: any,
messageId?: string,
variables: BotMessageVariables = {},
) {
const audience = flow.audiences.find((item) =>
item.intents.some((intentItem) => Number(intentItem.id) === Number(current.triage_intent_id)),
);
const intent = audience?.intents.find((item) => Number(item.id) === Number(current.triage_intent_id));
if (!intent) {
const fallbackAreaId = flow.fallback_area_id || (await this.getAreaByName('Suporte')).id;
const assignment = await this.queueChat(chatId, fallbackAreaId, `Fallback configurado pelo ${VIRTUAL_AGENT_NAME}`);
const assignment = await this.queueChat(
chatId,
fallbackAreaId,
this.buildVirtualAgentTransferNote({
name: variables.nome,
fallbackReason: 'não teve a solicitação classificada pelo Agente Virtual',
}),
);
await this.markBotRoute(chatId, messageId);
return {
assignment,
@ -846,7 +889,11 @@ export class WhatsappAssignmentService implements OnModuleInit {
const assignment = await this.queueChat(
chatId,
intent.area_id,
`Cliente solicitou especialista apos orientacao do ${VIRTUAL_AGENT_NAME}: ${intent.label}`,
this.buildVirtualAgentTransferNote({
name: variables.nome,
audienceLabel: audience?.label,
intentLabel: intent.label,
}),
);
await this.markBotRoute(chatId, messageId);
@ -856,7 +903,7 @@ export class WhatsappAssignmentService implements OnModuleInit {
botMessage: this.formatSenderMessage(
'Atendente virtual',
VIRTUAL_AGENT_NAME,
intent.escalation_message || `Certo, vou encaminhar seu atendimento para ${intent.area_nome}.`,
intent.escalation_message || 'Certo, vou encaminhar seu atendimento para o time responsável.',
),
};
}
@ -1249,6 +1296,58 @@ export class WhatsappAssignmentService implements OnModuleInit {
return ['ferias'].includes(normalized) ? 'as' : 'o';
}
private buildVirtualAgentTransferNote(input: {
name?: string | null;
audienceLabel?: string | null;
intentLabel?: string | null;
fallbackReason?: string;
}) {
const subject = this.getFirstName(input.name) || 'Cliente';
const audienceDescription = this.getAudienceDescription(input.audienceLabel);
const topic = this.getTopicLabel(input.intentLabel);
if (audienceDescription && topic) {
return `${subject} ${audienceDescription} e quer saber sobre ${topic}`;
}
if (topic) {
return `${subject} quer saber sobre ${topic}`;
}
if (audienceDescription && input.fallbackReason) {
return `${subject} ${audienceDescription} e ${input.fallbackReason}`;
}
if (input.fallbackReason) {
return `${subject} ${input.fallbackReason}`;
}
return `Roteado pelo ${VIRTUAL_AGENT_NAME}`;
}
private getAudienceDescription(value?: string | null) {
const normalized = this.normalize(value || '');
if (!normalized) return '';
if (normalized.includes('ex-colaborador') || normalized.includes('ex colaborador')) return 'é um ex-colaborador';
if (normalized.includes('candidato') || normalized.includes('vaga')) return 'é um candidato';
if (normalized.includes('colaborador') || normalized.includes('funcionario')) return 'é um colaborador';
return '';
}
private getTopicLabel(value?: string | null) {
const text = this.cleanVariable(value);
const normalized = this.normalize(text);
const knownTopics: Record<string, string> = {
beneficios: 'Benefícios',
ferias: 'Férias',
rescisao: 'Rescisão',
};
return knownTopics[normalized] || text;
}
private applyBotVariables(message: string, variables: BotMessageVariables) {
const firstName = this.getFirstName(variables.nome);
const fullName = this.cleanVariable(variables.nome);