diff --git a/scripts/ensure-port-free.js b/scripts/ensure-port-free.js index db16aed..10e4d7e 100644 --- a/scripts/ensure-port-free.js +++ b/scripts/ensure-port-free.js @@ -73,8 +73,65 @@ function killPid(pid) { 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(); +killWhatsappSessionBrowsers(); + try { const pids = process.platform === 'win32' ? getWindowsPids(port) : getUnixPids(port); diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 0aad8cc..5b2804f 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; import { AdminAccessController } from './admin-access.controller'; 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({ - controllers: [AdminAccessController], - providers: [AdminAccessService], + controllers: [AdminAccessController, AgentNotesController, CustomerContactsController], + providers: [AdminAccessService, AgentNotesService, CustomerContactsService], }) export class AdminModule {} diff --git a/src/modules/admin/agent-notes.controller.ts b/src/modules/admin/agent-notes.controller.ts new file mode 100644 index 0000000..a72411c --- /dev/null +++ b/src/modules/admin/agent-notes.controller.ts @@ -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)); + } +} diff --git a/src/modules/admin/agent-notes.service.ts b/src/modules/admin/agent-notes.service.ts new file mode 100644 index 0000000..41b414e --- /dev/null +++ b/src/modules/admin/agent-notes.service.ts @@ -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 }; + } +} diff --git a/src/modules/admin/customer-contacts.controller.ts b/src/modules/admin/customer-contacts.controller.ts new file mode 100644 index 0000000..75195f9 --- /dev/null +++ b/src/modules/admin/customer-contacts.controller.ts @@ -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, + }); + } +} diff --git a/src/modules/admin/customer-contacts.service.ts b/src/modules/admin/customer-contacts.service.ts new file mode 100644 index 0000000..f2b2ec9 --- /dev/null +++ b/src/modules/admin/customer-contacts.service.ts @@ -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; + } +} diff --git a/src/modules/whatsapp/whatsapp-assignment.service.ts b/src/modules/whatsapp/whatsapp-assignment.service.ts index 6303f67..4880e5e 100644 --- a/src/modules/whatsapp/whatsapp-assignment.service.ts +++ b/src/modules/whatsapp/whatsapp-assignment.service.ts @@ -185,6 +185,20 @@ export class WhatsappAssignmentService implements OnModuleInit { 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) { const cleanMessage = (message || '').trim(); if (!cleanMessage) { diff --git a/src/modules/whatsapp/whatsapp.service.ts b/src/modules/whatsapp/whatsapp.service.ts index cdf7875..ac7cff4 100644 --- a/src/modules/whatsapp/whatsapp.service.ts +++ b/src/modules/whatsapp/whatsapp.service.ts @@ -14,6 +14,7 @@ export class WhatsappService implements OnModuleInit { private currentQr: string | null = null; private readonly processedIncomingMessages = new Set(); private readonly incomingRoutingQueues = new Map>(); + private readonly chatMessagesCache = new Map(); constructor( private readonly gateway: WhatsappGateway, @@ -135,7 +136,8 @@ export class WhatsappService implements OnModuleInit { name: msg['_data']?.notifyName || remoteJid.split('@')[0], preview: msg.hasMedia ? `[Mídia: ${mediaData?.filename || 'Arquivo'}]` : (msg.body || '[Mídia]'), timestamp: msg.timestamp, - unreadCount: msg.fromMe ? 0 : 1 + unreadCount: msg.fromMe ? 0 : 1, + lastMessageFromMe: 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; const chats = await this.loadPersistentChats(); @@ -256,7 +258,8 @@ export class WhatsappService implements OnModuleInit { pinned: false, isLocked: 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); @@ -275,9 +278,13 @@ export class WhatsappService implements OnModuleInit { let liveChats: any[] = []; try { - liveChats = await this.client.getChats(); + liveChats = await this.retryWhatsappRead(() => this.client.getChats()); } 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(); @@ -311,7 +318,8 @@ export class WhatsappService implements OnModuleInit { pinned: c.pinned || false, isLocked: c.isLocked || 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 return Promise.all(conversas.map(async chat => { 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 { ...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 }; })); } + 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) { 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) { if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado'); this.logger.log(`Buscando mídia do chat ${chatId}, mensagem ${messageId}`); - const chat = await this.client.getChatById(chatId); - const messages = await chat.fetchMessages({ limit: 50 }); + const messages = await this.retryWhatsappRead(async () => { + const chat = await this.client.getChatById(chatId); + return chat.fetchMessages({ limit: 50 }); + }); const msg = messages.find(m => m.id._serialized === messageId); if (msg && msg.hasMedia) { @@ -360,6 +461,37 @@ export class WhatsappService implements OnModuleInit { throw new Error('Mídia não encontrada para esta mensagem'); } + private async retryWhatsappRead(operation: () => Promise, retries = 1): Promise { + 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) { 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], preview: media ? `[Mídia: ${media.filename || 'Arquivo'}]` : message, timestamp: Math.floor(Date.now() / 1000), - unreadCount: 0 + unreadCount: 0, + lastMessageFromMe: true }); + await this.assignmentService.clearTransferNote(to); return sentMsg; }