FEAT: Implementa controle de notas de agentes e contatos de clientes

This commit is contained in:
Rafael Alves Lopes 2026-05-19 17:58:48 -03:00
parent 8f3cb9f75f
commit 5135d0b2ed
8 changed files with 425 additions and 13 deletions

View File

@ -73,8 +73,65 @@ function killPid(pid) {
execSync(`kill -TERM ${pid}`, { stdio: 'ignore' }); execSync(`kill -TERM ${pid}`, { stdio: 'ignore' });
} }
function getWindowsWhatsappSessionPids() {
const sessionPath = path.resolve(process.cwd(), 'whatsapp-session');
const escapedSessionPath = sessionPath.replace(/\\/g, '\\\\');
const command = [
'Get-CimInstance Win32_Process',
`Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like '*${escapedSessionPath}*' }`,
'Select-Object -ExpandProperty ProcessId',
].join(' | ');
const output = execSync(`powershell -NoProfile -Command "${command}"`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return Array.from(
new Set(
output
.split(/\r?\n/)
.map((pid) => pid.trim())
.filter(Boolean)
.filter((pid) => pid !== String(process.pid)),
),
);
}
function getUnixWhatsappSessionPids() {
const sessionPath = path.resolve(process.cwd(), 'whatsapp-session');
const output = execSync(`pgrep -f "${sessionPath}"`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return Array.from(
new Set(
output
.split(/\r?\n/)
.map((pid) => pid.trim())
.filter(Boolean)
.filter((pid) => pid !== String(process.pid)),
),
);
}
function killWhatsappSessionBrowsers() {
try {
const pids = process.platform === 'win32' ? getWindowsWhatsappSessionPids() : getUnixWhatsappSessionPids();
if (!pids.length) return;
pids.forEach(killPid);
console.log(`Sessao WhatsApp liberada. Processo(s) Chrome encerrado(s): ${pids.join(', ')}`);
} catch {
// Se nao houver processo usando a sessao, seguimos normalmente.
}
}
const port = getConfiguredPort(); const port = getConfiguredPort();
killWhatsappSessionBrowsers();
try { try {
const pids = process.platform === 'win32' ? getWindowsPids(port) : getUnixPids(port); const pids = process.platform === 'win32' ? getWindowsPids(port) : getUnixPids(port);

View File

@ -1,9 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminAccessController } from './admin-access.controller'; import { AdminAccessController } from './admin-access.controller';
import { AdminAccessService } from './admin-access.service'; import { AdminAccessService } from './admin-access.service';
import { AgentNotesController } from './agent-notes.controller';
import { AgentNotesService } from './agent-notes.service';
import { CustomerContactsController } from './customer-contacts.controller';
import { CustomerContactsService } from './customer-contacts.service';
@Module({ @Module({
controllers: [AdminAccessController], controllers: [AdminAccessController, AgentNotesController, CustomerContactsController],
providers: [AdminAccessService], providers: [AdminAccessService, AgentNotesService, CustomerContactsService],
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -0,0 +1,22 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { AgentNotesService } from './agent-notes.service';
@Controller('agent/notes')
export class AgentNotesController {
constructor(private readonly agentNotesService: AgentNotesService) {}
@Get()
listNotes(@Query('userId') userId: string) {
return this.agentNotesService.listNotes(Number(userId));
}
@Post()
createNote(@Body() body: { userId: number; text: string }) {
return this.agentNotesService.createNote(Number(body.userId), body.text);
}
@Delete(':id')
deleteNote(@Param('id') id: string, @Query('userId') userId: string) {
return this.agentNotesService.deleteNote(Number(userId), Number(id));
}
}

View File

@ -0,0 +1,57 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DatabaseService } from '../../infra/database/database.service';
@Injectable()
export class AgentNotesService implements OnModuleInit {
constructor(private readonly database: DatabaseService) {}
async onModuleInit() {
await this.database.query(`
CREATE TABLE IF NOT EXISTS agent_notes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
}
async listNotes(userId: number) {
const result = await this.database.query(
`
SELECT id, user_id, text, created_at
FROM agent_notes
WHERE user_id = $1
ORDER BY created_at DESC
`,
[userId],
);
return result.rows;
}
async createNote(userId: number, text: string) {
const result = await this.database.query(
`
INSERT INTO agent_notes (user_id, text)
VALUES ($1, $2)
RETURNING id, user_id, text, created_at
`,
[userId, text],
);
return result.rows[0];
}
async deleteNote(userId: number, noteId: number) {
await this.database.query(
`
DELETE FROM agent_notes
WHERE id = $1 AND user_id = $2
`,
[noteId, userId],
);
return { ok: true };
}
}

View File

@ -0,0 +1,27 @@
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { CustomerContactsService } from './customer-contacts.service';
@Controller('contacts')
export class CustomerContactsController {
constructor(private readonly customerContactsService: CustomerContactsService) {}
@Get(':chatId')
getContact(@Param('chatId') chatId: string) {
return this.customerContactsService.getContact(decodeURIComponent(chatId));
}
@Put(':chatId')
saveContact(
@Param('chatId') chatId: string,
@Body() body: { phone?: string | null; name?: string | null; company?: string | null; note?: string | null; userId?: number | null },
) {
return this.customerContactsService.saveContact({
chatId: decodeURIComponent(chatId),
phone: body.phone,
name: body.name,
company: body.company,
note: body.note,
userId: body.userId,
});
}
}

View File

@ -0,0 +1,97 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DatabaseService } from '../../infra/database/database.service';
interface SaveContactInput {
chatId: string;
phone?: string | null;
name?: string | null;
company?: string | null;
note?: string | null;
userId?: number | null;
}
@Injectable()
export class CustomerContactsService implements OnModuleInit {
constructor(private readonly database: DatabaseService) {}
async onModuleInit() {
await this.database.query(`
CREATE TABLE IF NOT EXISTS agenda_contatos (
chat_id VARCHAR(255) PRIMARY KEY,
phone VARCHAR(80) NOT NULL,
name VARCHAR(255),
company VARCHAR(255),
note TEXT,
updated_by_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
}
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
FROM agenda_contatos
WHERE chat_id = $1
LIMIT 1
`,
[chatId],
);
return result.rows[0] || this.buildDefaultContact(chatId);
}
async saveContact(input: SaveContactInput) {
const result = await this.database.query(
`
INSERT INTO agenda_contatos (
chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (chat_id) DO UPDATE SET
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
`,
[
input.chatId,
this.normalizePhone(input.phone, input.chatId),
this.normalizeText(input.name),
this.normalizeText(input.company),
this.normalizeText(input.note),
input.userId || null,
],
);
return result.rows[0];
}
private buildDefaultContact(chatId: string) {
return {
chat_id: chatId,
phone: this.normalizePhone(null, chatId),
name: null,
company: null,
note: null,
updated_by_user_id: null,
created_at: null,
updated_at: null,
};
}
private normalizePhone(phone: string | null | undefined, chatId: string) {
const cleanPhone = String(phone || '').trim();
if (cleanPhone) return cleanPhone;
return chatId.endsWith('@lid') ? '' : String(chatId || '').split('@')[0];
}
private normalizeText(value?: string | null) {
const text = String(value || '').trim();
return text || null;
}
}

View File

@ -185,6 +185,20 @@ export class WhatsappAssignmentService implements OnModuleInit {
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null; return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
} }
async clearTransferNote(chatId: string) {
const result = await this.db.query(
`
UPDATE whatsapp_chat_atribuicoes
SET transfer_note = NULL, updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
RETURNING *
`,
[chatId],
);
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
}
async routeIncomingMessage(chatId: string, message: string, messageId?: string) { async routeIncomingMessage(chatId: string, message: string, messageId?: string) {
const cleanMessage = (message || '').trim(); const cleanMessage = (message || '').trim();
if (!cleanMessage) { if (!cleanMessage) {

View File

@ -14,6 +14,7 @@ export class WhatsappService implements OnModuleInit {
private currentQr: string | null = null; private currentQr: string | null = null;
private readonly processedIncomingMessages = new Set<string>(); private readonly processedIncomingMessages = new Set<string>();
private readonly incomingRoutingQueues = new Map<string, Promise<void>>(); private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
private readonly chatMessagesCache = new Map<string, any[]>();
constructor( constructor(
private readonly gateway: WhatsappGateway, private readonly gateway: WhatsappGateway,
@ -135,7 +136,8 @@ export class WhatsappService implements OnModuleInit {
name: msg['_data']?.notifyName || remoteJid.split('@')[0], name: msg['_data']?.notifyName || remoteJid.split('@')[0],
preview: msg.hasMedia ? `[Mídia: ${mediaData?.filename || 'Arquivo'}]` : (msg.body || '[Mídia]'), preview: msg.hasMedia ? `[Mídia: ${mediaData?.filename || 'Arquivo'}]` : (msg.body || '[Mídia]'),
timestamp: msg.timestamp, timestamp: msg.timestamp,
unreadCount: msg.fromMe ? 0 : 1 unreadCount: msg.fromMe ? 0 : 1,
lastMessageFromMe: msg.fromMe
}); });
if (!msg.fromMe) { if (!msg.fromMe) {
@ -214,7 +216,7 @@ export class WhatsappService implements OnModuleInit {
} }
} }
private async addOrUpdatePersistentChat(chatId: string, data: { name?: string, preview?: string, timestamp?: number, unreadCount?: number }) { private async addOrUpdatePersistentChat(chatId: string, data: { name?: string, preview?: string, timestamp?: number, unreadCount?: number, lastMessageFromMe?: boolean }) {
if (chatId === 'status@broadcast' || chatId.endsWith('@g.us')) return; if (chatId === 'status@broadcast' || chatId.endsWith('@g.us')) return;
const chats = await this.loadPersistentChats(); const chats = await this.loadPersistentChats();
@ -256,7 +258,8 @@ export class WhatsappService implements OnModuleInit {
pinned: false, pinned: false,
isLocked: false, isLocked: false,
isMuted: false, isMuted: false,
preview: data.preview || existing.preview || '' preview: data.preview || existing.preview || '',
lastMessageFromMe: data.lastMessageFromMe !== undefined ? data.lastMessageFromMe : Boolean(existing.lastMessageFromMe)
}; };
await this.savePersistentChats(chats); await this.savePersistentChats(chats);
@ -275,9 +278,13 @@ export class WhatsappService implements OnModuleInit {
let liveChats: any[] = []; let liveChats: any[] = [];
try { try {
liveChats = await this.client.getChats(); liveChats = await this.retryWhatsappRead(() => this.client.getChats());
} catch (err) { } catch (err) {
this.logger.error('Erro ao chamar client.getChats():', err); if (this.isTransientWhatsappFrameError(err)) {
this.logger.warn(`WhatsApp Web ainda estabilizando ao carregar chats: ${this.getErrorMessage(err)}`);
} else {
this.logger.error('Erro ao chamar client.getChats():', err);
}
} }
const persistentChatsObj = await this.loadPersistentChats(); const persistentChatsObj = await this.loadPersistentChats();
@ -311,7 +318,8 @@ export class WhatsappService implements OnModuleInit {
pinned: c.pinned || false, pinned: c.pinned || false,
isLocked: c.isLocked || false, isLocked: c.isLocked || false,
isMuted: c.isMuted || false, isMuted: c.isMuted || false,
preview: c.lastMessage ? (c.lastMessage.body || '[Mídia]') : (existingPersistent.preview || '') preview: c.lastMessage ? (c.lastMessage.body || '[Mídia]') : (existingPersistent.preview || ''),
lastMessageFromMe: c.lastMessage?.fromMe !== undefined ? Boolean(c.lastMessage.fromMe) : Boolean(existingPersistent.lastMessageFromMe)
}); });
} }
}); });
@ -324,25 +332,118 @@ export class WhatsappService implements OnModuleInit {
// Buscar todas as atribuições para enriquecer os chats // Buscar todas as atribuições para enriquecer os chats
return Promise.all(conversas.map(async chat => { return Promise.all(conversas.map(async chat => {
const assignment = await this.assignmentService.getAssignment(chat.id._serialized); const assignment = await this.assignmentService.getAssignment(chat.id._serialized);
const contactProfile = await this.getCustomerContact(chat.id._serialized);
const phone = contactProfile?.phone || await this.resolveContactPhone(chat.id._serialized);
return { return {
...chat, ...chat,
name: contactProfile?.name || chat.name,
contactProfile: contactProfile
? { ...contactProfile, phone }
: {
chat_id: chat.id._serialized,
phone,
name: null,
company: null,
note: null,
},
assignment: assignment || null assignment: assignment || null
}; };
})); }));
} }
private async resolveContactPhone(chatId: string) {
try {
const lidPhone = await this.resolveLidPhone(chatId);
if (lidPhone) return lidPhone;
const contact = await this.retryWhatsappRead(() => this.client.getContactById(chatId));
const phone =
(contact as any)?.number ||
(contact as any)?.phoneNumber ||
(contact as any)?.id?.user ||
'';
if (phone && !String(phone).includes('@') && !chatId.includes(`${phone}@lid`)) {
return String(phone);
}
const formattedNumber = await contact.getFormattedNumber().catch(() => '');
return formattedNumber || (chatId.endsWith('@lid') ? '' : chatId.split('@')[0]);
} catch {
return chatId.endsWith('@lid') ? '' : chatId.split('@')[0];
}
}
private async resolveLidPhone(chatId: string) {
if (!chatId.endsWith('@lid')) return '';
try {
const page = (this.client as any).pupPage;
if (!page) return '';
const phone = await this.retryWhatsappRead(() =>
page.evaluate(async (serializedChatId: string) => {
try {
const result = await (window as any).WWebJS?.enforceLidAndPnRetrieval?.(serializedChatId);
return result?.phone?._serialized || result?.phone?.user || '';
} catch {
return '';
}
}, chatId),
);
return phone ? String(phone).split('@')[0] : '';
} catch {
return '';
}
}
private async getCustomerContact(chatId: string) {
try {
const result = await this.db.query(
`
SELECT chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
FROM agenda_contatos
WHERE chat_id = $1
LIMIT 1
`,
[chatId],
);
return result.rows[0] || null;
} catch {
return null;
}
}
async getChatMessages(chatId: string) { async getChatMessages(chatId: string) {
if (this.status !== 'CONNECTED') return []; if (this.status !== 'CONNECTED') return [];
const chat = await this.client.getChatById(chatId);
return chat.fetchMessages({ limit: 50 }); try {
const messages = await this.retryWhatsappRead(async () => {
const chat = await this.client.getChatById(chatId);
return chat.fetchMessages({ limit: 50 });
});
this.chatMessagesCache.set(chatId, messages);
return messages;
} catch (err) {
const cachedMessages = this.chatMessagesCache.get(chatId) || [];
if (this.isTransientWhatsappFrameError(err)) {
this.logger.warn(`WhatsApp Web ainda estabilizando ao carregar mensagens de ${chatId}. Retornando cache local (${cachedMessages.length}).`);
} else {
this.logger.error(`Erro ao carregar mensagens de ${chatId}. Retornando cache local (${cachedMessages.length}).`, err);
}
return cachedMessages;
}
} }
async getMessageMedia(chatId: string, messageId: string) { async getMessageMedia(chatId: string, messageId: string) {
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado'); if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
this.logger.log(`Buscando mídia do chat ${chatId}, mensagem ${messageId}`); this.logger.log(`Buscando mídia do chat ${chatId}, mensagem ${messageId}`);
const chat = await this.client.getChatById(chatId); const messages = await this.retryWhatsappRead(async () => {
const messages = await chat.fetchMessages({ limit: 50 }); const chat = await this.client.getChatById(chatId);
return chat.fetchMessages({ limit: 50 });
});
const msg = messages.find(m => m.id._serialized === messageId); const msg = messages.find(m => m.id._serialized === messageId);
if (msg && msg.hasMedia) { if (msg && msg.hasMedia) {
@ -360,6 +461,37 @@ export class WhatsappService implements OnModuleInit {
throw new Error('Mídia não encontrada para esta mensagem'); throw new Error('Mídia não encontrada para esta mensagem');
} }
private async retryWhatsappRead<T>(operation: () => Promise<T>, retries = 1): Promise<T> {
try {
return await operation();
} catch (err) {
if (retries <= 0 || !this.isTransientWhatsappFrameError(err)) {
throw err;
}
await this.sleep(350);
return this.retryWhatsappRead(operation, retries - 1);
}
}
private sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private isTransientWhatsappFrameError(err: unknown) {
const message = this.getErrorMessage(err).toLowerCase();
return (
message.includes('detached frame') ||
message.includes('execution context was destroyed') ||
message.includes('most likely because of a navigation')
);
}
private getErrorMessage(err: unknown) {
if (err instanceof Error) return err.message;
return String(err || '');
}
async sendMessage(to: string, message: string, media?: { data: string; mimetype: string; filename?: string }, senderName?: string) { async sendMessage(to: string, message: string, media?: { data: string; mimetype: string; filename?: string }, senderName?: string) {
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado'); if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
@ -379,8 +511,10 @@ export class WhatsappService implements OnModuleInit {
name: to.split('@')[0], name: to.split('@')[0],
preview: media ? `[Mídia: ${media.filename || 'Arquivo'}]` : message, preview: media ? `[Mídia: ${media.filename || 'Arquivo'}]` : message,
timestamp: Math.floor(Date.now() / 1000), timestamp: Math.floor(Date.now() / 1000),
unreadCount: 0 unreadCount: 0,
lastMessageFromMe: true
}); });
await this.assignmentService.clearTransferNote(to);
return sentMsg; return sentMsg;
} }