diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 5b2804f..4b5a5c2 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -3,11 +3,14 @@ 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 { AgentPresenceController } from './agent-presence.controller'; +import { AgentPresenceService } from './agent-presence.service'; import { CustomerContactsController } from './customer-contacts.controller'; import { CustomerContactsService } from './customer-contacts.service'; @Module({ - controllers: [AdminAccessController, AgentNotesController, CustomerContactsController], - providers: [AdminAccessService, AgentNotesService, CustomerContactsService], + controllers: [AdminAccessController, AgentNotesController, AgentPresenceController, CustomerContactsController], + providers: [AdminAccessService, AgentNotesService, AgentPresenceService, CustomerContactsService], + exports: [AgentPresenceService], }) export class AdminModule {} diff --git a/src/modules/admin/agent-presence.controller.ts b/src/modules/admin/agent-presence.controller.ts new file mode 100644 index 0000000..b5b2bf8 --- /dev/null +++ b/src/modules/admin/agent-presence.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { AgentPresenceService } from './agent-presence.service'; + +@Controller('agent/presence') +export class AgentPresenceController { + constructor(private readonly agentPresenceService: AgentPresenceService) {} + + @Get() + listPresence() { + return this.agentPresenceService.listPresence(); + } + + @Get('me') + getPresence(@Query('userId') userId: string) { + return this.agentPresenceService.getPresence(Number(userId)); + } + + @Post('pause') + pause(@Body() body: { userId: number }) { + return this.agentPresenceService.pause(Number(body.userId)); + } + + @Post('resume') + resume(@Body() body: { userId: number }) { + return this.agentPresenceService.resume(Number(body.userId)); + } + + @Post('offline') + offline(@Body() body: { userId: number }) { + return this.agentPresenceService.offline(Number(body.userId)); + } +} diff --git a/src/modules/admin/agent-presence.service.ts b/src/modules/admin/agent-presence.service.ts new file mode 100644 index 0000000..fc4df0e --- /dev/null +++ b/src/modules/admin/agent-presence.service.ts @@ -0,0 +1,260 @@ +import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common'; +import { DatabaseService } from '../../infra/database/database.service'; + +type AgentPresenceStatus = 'available' | 'paused' | 'offline'; + +@Injectable() +export class AgentPresenceService implements OnModuleInit { + constructor(private readonly database: DatabaseService) {} + + async onModuleInit() { + await this.ensureSchema(); + } + + async ensureSchema() { + await this.database.query(` + CREATE TABLE IF NOT EXISTS agent_presence ( + user_id INTEGER PRIMARY KEY REFERENCES usuarios(id) ON DELETE CASCADE, + status VARCHAR(40) NOT NULL DEFAULT 'offline', + paused_at TIMESTAMP WITH TIME ZONE, + last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `); + + await this.database.query(` + ALTER TABLE whatsapp_chat_atribuicoes + ADD COLUMN IF NOT EXISTS reserved_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS pause_released_at TIMESTAMP WITH TIME ZONE; + `); + } + + async listPresence() { + const result = await this.database.query(` + SELECT + u.id AS user_id, + u.nome, + u.email, + COALESCE(ap.status, 'offline') AS status, + ap.paused_at, + ap.last_seen_at, + ap.updated_at, + CASE + WHEN ap.status = 'paused' AND ap.paused_at IS NOT NULL + THEN FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - ap.paused_at)))::INTEGER + ELSE 0 + END AS paused_seconds, + COUNT(DISTINCT assigned.chat_id)::INTEGER AS assigned_count, + COUNT(DISTINCT reserved.chat_id)::INTEGER AS reserved_count + FROM usuarios u + LEFT JOIN agent_presence ap ON ap.user_id = u.id + LEFT JOIN whatsapp_chat_atribuicoes assigned + ON assigned.user_id = u.id + AND assigned.status = 'assigned' + LEFT JOIN whatsapp_chat_atribuicoes reserved + ON reserved.reserved_user_id = u.id + AND reserved.status = 'queued' + AND reserved.user_id IS NULL + WHERE u.ativo = TRUE + GROUP BY u.id, u.nome, u.email, ap.status, ap.paused_at, ap.last_seen_at, ap.updated_at + ORDER BY u.nome ASC + `); + + return result.rows; + } + + async getPresence(userId: number) { + this.assertUserId(userId); + await this.touchPresence(userId, 'available', false); + + const result = await this.database.query( + ` + SELECT + user_id, + status, + paused_at, + last_seen_at, + updated_at, + CASE + WHEN status = 'paused' AND paused_at IS NOT NULL + THEN FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - paused_at)))::INTEGER + ELSE 0 + END AS paused_seconds + FROM agent_presence + WHERE user_id = $1 + `, + [userId], + ); + + return result.rows[0]; + } + + async pause(userId: number) { + this.assertUserId(userId); + + return this.database.transaction(async (client) => { + const presence = await client.query( + ` + INSERT INTO agent_presence (user_id, status, paused_at, last_seen_at, updated_at) + VALUES ($1, 'paused', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) DO UPDATE SET + status = 'paused', + paused_at = COALESCE(agent_presence.paused_at, CURRENT_TIMESTAMP), + last_seen_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + RETURNING * + `, + [userId], + ); + + const released = await client.query( + ` + UPDATE whatsapp_chat_atribuicoes + SET + reserved_user_id = $1, + reserved_at = CURRENT_TIMESTAMP, + pause_released_at = CURRENT_TIMESTAMP, + user_id = NULL, + status = 'queued', + updated_at = CURRENT_TIMESTAMP + WHERE user_id = $1 + AND status = 'assigned' + RETURNING * + `, + [userId], + ); + + return { + presence: presence.rows[0], + releasedCount: released.rowCount, + releasedChats: released.rows, + }; + }); + } + + async resume(userId: number) { + this.assertUserId(userId); + + return this.database.transaction(async (client) => { + const presence = await client.query( + ` + INSERT INTO agent_presence (user_id, status, paused_at, last_seen_at, updated_at) + VALUES ($1, 'available', NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) DO UPDATE SET + status = 'available', + paused_at = NULL, + last_seen_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + RETURNING * + `, + [userId], + ); + + const restored = await client.query( + ` + UPDATE whatsapp_chat_atribuicoes + SET + user_id = $1, + status = 'assigned', + assigned_at = CURRENT_TIMESTAMP, + reserved_user_id = NULL, + reserved_at = NULL, + pause_released_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE reserved_user_id = $1 + AND status = 'queued' + AND user_id IS NULL + RETURNING * + `, + [userId], + ); + + return { + presence: presence.rows[0], + restoredCount: restored.rowCount, + restoredChats: restored.rows, + }; + }); + } + + async offline(userId: number) { + this.assertUserId(userId); + + return this.database.transaction(async (client) => { + const presence = await client.query( + ` + INSERT INTO agent_presence (user_id, status, paused_at, last_seen_at, updated_at) + VALUES ($1, 'offline', NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) DO UPDATE SET + status = 'offline', + paused_at = NULL, + last_seen_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + RETURNING * + `, + [userId], + ); + + const released = await client.query( + ` + UPDATE whatsapp_chat_atribuicoes + SET + user_id = NULL, + status = 'queued', + reserved_user_id = NULL, + reserved_at = NULL, + pause_released_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE (user_id = $1 OR reserved_user_id = $1) + AND status IN ('assigned', 'queued') + RETURNING * + `, + [userId], + ); + + return { + presence: presence.rows[0], + releasedCount: released.rowCount, + releasedChats: released.rows, + }; + }); + } + + async isAvailable(userId: number) { + this.assertUserId(userId); + const result = await this.database.query<{ status: AgentPresenceStatus }>( + ` + SELECT COALESCE(ap.status, 'offline') AS status + FROM usuarios u + LEFT JOIN agent_presence ap ON ap.user_id = u.id + WHERE u.id = $1 + AND u.ativo = TRUE + LIMIT 1 + `, + [userId], + ); + + return result.rows[0]?.status === 'available'; + } + + private async touchPresence(userId: number, fallbackStatus: AgentPresenceStatus, updateStatus = true) { + await this.database.query( + ` + INSERT INTO agent_presence (user_id, status, last_seen_at, updated_at) + VALUES ($1, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) DO UPDATE SET + status = CASE WHEN $3 THEN EXCLUDED.status ELSE agent_presence.status END, + last_seen_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + `, + [userId, fallbackStatus, updateStatus], + ); + } + + private assertUserId(userId: number) { + if (!Number.isFinite(userId) || userId <= 0) { + throw new BadRequestException('Usuario invalido para controle de presenca.'); + } + } +} diff --git a/src/modules/whatsapp/whatsapp-assignment.service.ts b/src/modules/whatsapp/whatsapp-assignment.service.ts index 3165c1d..d78fdac 100644 --- a/src/modules/whatsapp/whatsapp-assignment.service.ts +++ b/src/modules/whatsapp/whatsapp-assignment.service.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { DatabaseService } from '../../infra/database/database.service'; +import { AgentPresenceService } from '../admin/agent-presence.service'; interface TransferInput { chatId: string; @@ -52,7 +53,10 @@ const SALES_KEYWORDS = [ export class WhatsappAssignmentService implements OnModuleInit { private readonly logger = new Logger(WhatsappAssignmentService.name); - constructor(private readonly db: DatabaseService) {} + constructor( + private readonly db: DatabaseService, + private readonly agentPresenceService: AgentPresenceService, + ) {} async onModuleInit() { await this.ensureSchema(); @@ -75,12 +79,16 @@ export class WhatsappAssignmentService implements OnModuleInit { ADD COLUMN IF NOT EXISTS last_routed_message_id VARCHAR(255), ADD COLUMN IF NOT EXISTS last_bot_sent_at TIMESTAMP WITH TIME ZONE, ADD COLUMN IF NOT EXISTS awaiting_customer_reply BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS reserved_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS pause_released_at TIMESTAMP WITH TIME ZONE, ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; `); } async assignChat(chatId: string, userId: string | number, areaId?: string | number | null) { this.logger.log(`Atribuindo chat ${chatId} ao usuario ${userId}`); + await this.assertUserCanReceiveAssignment(Number(userId)); const query = ` INSERT INTO whatsapp_chat_atribuicoes ( @@ -92,6 +100,9 @@ export class WhatsappAssignmentService implements OnModuleInit { area_id = COALESCE(EXCLUDED.area_id, whatsapp_chat_atribuicoes.area_id), status = 'assigned', awaiting_customer_reply = FALSE, + reserved_user_id = NULL, + reserved_at = NULL, + pause_released_at = NULL, assigned_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP RETURNING *; @@ -111,6 +122,9 @@ export class WhatsappAssignmentService implements OnModuleInit { user_id = NULL, area_id = EXCLUDED.area_id, status = 'queued', + reserved_user_id = NULL, + reserved_at = NULL, + pause_released_at = NULL, conversation_started_at = CASE WHEN whatsapp_chat_atribuicoes.expires_at <= CURRENT_TIMESTAMP THEN CURRENT_TIMESTAMP ELSE whatsapp_chat_atribuicoes.conversation_started_at @@ -130,6 +144,10 @@ export class WhatsappAssignmentService implements OnModuleInit { async transferChat(input: TransferInput) { const status = input.userId ? 'assigned' : 'queued'; + if (input.userId) { + await this.assertUserCanReceiveAssignment(input.userId); + } + const query = ` INSERT INTO whatsapp_chat_atribuicoes ( chat_id, user_id, area_id, status, conversation_started_at, expires_at, transfer_note, assigned_at, updated_at @@ -140,6 +158,9 @@ export class WhatsappAssignmentService implements OnModuleInit { area_id = EXCLUDED.area_id, status = EXCLUDED.status, transfer_note = EXCLUDED.transfer_note, + reserved_user_id = NULL, + reserved_at = NULL, + pause_released_at = NULL, assigned_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP RETURNING *; @@ -181,7 +202,13 @@ export class WhatsappAssignmentService implements OnModuleInit { this.logger.log(`Liberando chat ${chatId}`); const query = ` UPDATE whatsapp_chat_atribuicoes - SET user_id = NULL, status = 'queued', updated_at = CURRENT_TIMESTAMP + SET + user_id = NULL, + status = 'queued', + reserved_user_id = NULL, + reserved_at = NULL, + pause_released_at = NULL, + updated_at = CURRENT_TIMESTAMP WHERE chat_id = $1 RETURNING *; `; @@ -347,6 +374,13 @@ export class WhatsappAssignmentService implements OnModuleInit { return this.getAreaByName(targetName); } + private async assertUserCanReceiveAssignment(userId: number) { + const canReceive = await this.agentPresenceService.isAvailable(userId); + if (!canReceive) { + throw new BadRequestException('Usuario indisponivel para receber atendimento.'); + } + } + private async getAreaByName(targetName: string) { const result = await this.db.query<{ id: number; nome: string }>( `SELECT id, nome FROM areas WHERE nome = $1 LIMIT 1`, diff --git a/src/modules/whatsapp/whatsapp.module.ts b/src/modules/whatsapp/whatsapp.module.ts index e42b81d..7983566 100644 --- a/src/modules/whatsapp/whatsapp.module.ts +++ b/src/modules/whatsapp/whatsapp.module.ts @@ -3,8 +3,10 @@ import { WhatsappService } from './whatsapp.service'; import { WhatsappGateway } from './whatsapp.gateway'; import { WhatsappController } from './whatsapp.controller'; import { WhatsappAssignmentService } from './whatsapp-assignment.service'; +import { AdminModule } from '../admin/admin.module'; @Module({ + imports: [AdminModule], providers: [WhatsappService, WhatsappGateway, WhatsappAssignmentService], controllers: [WhatsappController], exports: [WhatsappService, WhatsappAssignmentService],