From 41dc1d78b580eac2dc12373f820fa1174cb0d6be Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Mon, 1 Jun 2026 16:16:48 -0300 Subject: [PATCH] =?UTF-8?q?REFACTOR:=20Modularizado=20agente=20e=20presen?= =?UTF-8?q?=C3=A7a=20operacional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Criado módulo agent para presença e notas do atendente - Removidas responsabilidades de agente do módulo admin - Movido SQL de presença e notas para repositories - Adicionados DTOs para rotas de agente - Presença e notas passam a usar usuário autenticado via JWT - Mantida compatibilidade com userId legado do frontend - Atualizada wiki backend de Admin, Agent e WhatsApp --- src/app.module.ts | 2 + src/modules/admin/admin.module.ts | 9 - src/modules/admin/agent-notes.controller.ts | 49 ---- src/modules/admin/agent-notes.service.ts | 57 ---- .../admin/agent-presence.controller.ts | 58 ---- src/modules/admin/agent-presence.service.ts | 260 ------------------ src/modules/agent/agent-notes.controller.ts | 64 +++++ src/modules/agent/agent-notes.service.ts | 43 +++ .../agent/agent-presence.controller.ts | 71 +++++ src/modules/agent/agent-presence.service.ts | 86 ++++++ src/modules/agent/agent.module.ts | 19 ++ src/modules/agent/dto/agent.dto.ts | 20 ++ .../repositories/agent-notes.repository.ts | 40 +++ .../repositories/agent-presence.repository.ts | 202 ++++++++++++++ .../attendance-assignment.service.ts | 2 +- src/modules/attendance/attendance.module.ts | 4 +- 16 files changed, 550 insertions(+), 436 deletions(-) delete mode 100644 src/modules/admin/agent-notes.controller.ts delete mode 100644 src/modules/admin/agent-notes.service.ts delete mode 100644 src/modules/admin/agent-presence.controller.ts delete mode 100644 src/modules/admin/agent-presence.service.ts create mode 100644 src/modules/agent/agent-notes.controller.ts create mode 100644 src/modules/agent/agent-notes.service.ts create mode 100644 src/modules/agent/agent-presence.controller.ts create mode 100644 src/modules/agent/agent-presence.service.ts create mode 100644 src/modules/agent/agent.module.ts create mode 100644 src/modules/agent/dto/agent.dto.ts create mode 100644 src/modules/agent/repositories/agent-notes.repository.ts create mode 100644 src/modules/agent/repositories/agent-presence.repository.ts diff --git a/src/app.module.ts b/src/app.module.ts index 1977124..8492cb6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { DatabaseModule } from './infra/database/database.module'; import { AdminModule } from './modules/admin/admin.module'; +import { AgentModule } from './modules/agent/agent.module'; import { AttendanceModule } from './modules/attendance/attendance.module'; import { AuthModule } from './modules/auth/auth.module'; import { ContactsModule } from './modules/contacts/contacts.module'; @@ -21,6 +22,7 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module'; DatabaseModule, AuthModule, AdminModule, + AgentModule, AttendanceModule, ContactsModule, KnowledgeBaseModule, diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 680617a..161cf87 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -1,24 +1,15 @@ 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 { AgentPresenceController } from './agent-presence.controller'; -import { AgentPresenceService } from './agent-presence.service'; import { AdminAccessRepository } from './repositories/admin-access.repository'; @Module({ controllers: [ AdminAccessController, - AgentNotesController, - AgentPresenceController, ], providers: [ AdminAccessService, AdminAccessRepository, - AgentNotesService, - AgentPresenceService, ], - exports: [AgentPresenceService], }) export class AdminModule {} diff --git a/src/modules/admin/agent-notes.controller.ts b/src/modules/admin/agent-notes.controller.ts deleted file mode 100644 index b8193ac..0000000 --- a/src/modules/admin/agent-notes.controller.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { AgentNotesService } from './agent-notes.service'; - -@ApiTags('Notas do agente') -@Controller('agent/notes') -export class AgentNotesController { - constructor(private readonly agentNotesService: AgentNotesService) {} - - @ApiOperation({ - summary: 'Lista notas do agente', - description: 'Retorna lembretes e anotacoes pessoais cadastradas por um agente no painel.', - }) - @ApiQuery({ name: 'userId', required: true, description: 'ID do usuario dono das notas.' }) - @Get() - listNotes(@Query('userId') userId: string) { - return this.agentNotesService.listNotes(Number(userId)); - } - - @ApiOperation({ - summary: 'Cria nota do agente', - description: 'Adiciona uma anotacao pessoal para organizacao do atendente.', - }) - @ApiBody({ - schema: { - type: 'object', - required: ['userId', 'text'], - properties: { - userId: { type: 'number', example: 10 }, - text: { type: 'string', example: 'Retornar contato no fim do dia.' }, - }, - }, - }) - @Post() - createNote(@Body() body: { userId: number; text: string }) { - return this.agentNotesService.createNote(Number(body.userId), body.text); - } - - @ApiOperation({ - summary: 'Remove nota do agente', - description: 'Exclui uma nota pessoal do agente validando o usuario dono da anotacao.', - }) - @ApiParam({ name: 'id', description: 'ID da nota.' }) - @ApiQuery({ name: 'userId', required: true, description: 'ID do usuario dono da nota.' }) - @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 deleted file mode 100644 index 41b414e..0000000 --- a/src/modules/admin/agent-notes.service.ts +++ /dev/null @@ -1,57 +0,0 @@ -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/agent-presence.controller.ts b/src/modules/admin/agent-presence.controller.ts deleted file mode 100644 index 2c8ff0c..0000000 --- a/src/modules/admin/agent-presence.controller.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { AgentPresenceService } from './agent-presence.service'; - -@ApiTags('Presenca do agente') -@Controller('agent/presence') -export class AgentPresenceController { - constructor(private readonly agentPresenceService: AgentPresenceService) {} - - @ApiOperation({ - summary: 'Lista presenca dos agentes', - description: 'Retorna o estado atual dos agentes para acompanhamento operacional, incluindo disponibilidade e pausas.', - }) - @Get() - listPresence() { - return this.agentPresenceService.listPresence(); - } - - @ApiOperation({ - summary: 'Consulta presenca do agente logado', - description: 'Retorna o status de presenca de um usuario especifico.', - }) - @ApiQuery({ name: 'userId', required: true, description: 'ID do usuario.' }) - @Get('me') - getPresence(@Query('userId') userId: string) { - return this.agentPresenceService.getPresence(Number(userId)); - } - - @ApiOperation({ - summary: 'Coloca agente em pausa', - description: 'Marca o agente como pausado para que ele nao receba novos atendimentos enquanto estiver indisponivel.', - }) - @ApiBody({ schema: { type: 'object', required: ['userId'], properties: { userId: { type: 'number', example: 10 } } } }) - @Post('pause') - pause(@Body() body: { userId: number }) { - return this.agentPresenceService.pause(Number(body.userId)); - } - - @ApiOperation({ - summary: 'Retoma disponibilidade do agente', - description: 'Remove a pausa do agente e marca o usuario como disponivel novamente.', - }) - @ApiBody({ schema: { type: 'object', required: ['userId'], properties: { userId: { type: 'number', example: 10 } } } }) - @Post('resume') - resume(@Body() body: { userId: number }) { - return this.agentPresenceService.resume(Number(body.userId)); - } - - @ApiOperation({ - summary: 'Marca agente como offline', - description: 'Atualiza a presenca do agente para offline, usado em saida do painel ou encerramento de expediente.', - }) - @ApiBody({ schema: { type: 'object', required: ['userId'], properties: { userId: { type: 'number', example: 10 } } } }) - @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 deleted file mode 100644 index fc4df0e..0000000 --- a/src/modules/admin/agent-presence.service.ts +++ /dev/null @@ -1,260 +0,0 @@ -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/agent/agent-notes.controller.ts b/src/modules/agent/agent-notes.controller.ts new file mode 100644 index 0000000..c92cf88 --- /dev/null +++ b/src/modules/agent/agent-notes.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { AgentNotesService } from './agent-notes.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { AuthTokenPayload } from '../auth/auth-token.service'; +import { CreateAgentNoteDto } from './dto/agent.dto'; + +@ApiTags('Notas do agente') +@Controller('agent/notes') +export class AgentNotesController { + constructor(private readonly agentNotesService: AgentNotesService) {} + + @ApiOperation({ + summary: 'Lista notas do agente', + description: 'Retorna lembretes e anotacoes pessoais cadastradas por um agente no painel.', + }) + @Roles('Admin', 'Supervisor', 'Agente') + @ApiQuery({ name: 'userId', required: false, description: 'Mantido por compatibilidade; o backend usa o usuario autenticado no JWT.' }) + @Get() + listNotes(@CurrentUser() user: AuthTokenPayload, @Query('userId') _userId?: string) { + return this.agentNotesService.listNotes(this.currentUserId(user)); + } + + @ApiOperation({ + summary: 'Cria nota do agente', + description: 'Adiciona uma anotacao pessoal para organizacao do atendente.', + }) + @Roles('Admin', 'Supervisor', 'Agente') + @ApiBody({ + schema: { + type: 'object', + required: ['text'], + properties: { + userId: { type: 'number', example: 10, description: 'Mantido por compatibilidade; ignorado pelo backend.' }, + text: { type: 'string', example: 'Retornar contato no fim do dia.' }, + }, + }, + }) + @Post() + createNote(@CurrentUser() user: AuthTokenPayload, @Body() body: CreateAgentNoteDto) { + return this.agentNotesService.createNote(this.currentUserId(user), body.text); + } + + @ApiOperation({ + summary: 'Remove nota do agente', + description: 'Exclui uma nota pessoal do agente validando o usuario dono da anotacao.', + }) + @Roles('Admin', 'Supervisor', 'Agente') + @ApiParam({ name: 'id', description: 'ID da nota.' }) + @ApiQuery({ name: 'userId', required: false, description: 'Mantido por compatibilidade; o backend usa o usuario autenticado no JWT.' }) + @Delete(':id') + deleteNote( + @CurrentUser() user: AuthTokenPayload, + @Param('id', ParseIntPipe) id: number, + @Query('userId') _userId?: string, + ) { + return this.agentNotesService.deleteNote(this.currentUserId(user), id); + } + + private currentUserId(user: AuthTokenPayload) { + return Number(user.sub); + } +} diff --git a/src/modules/agent/agent-notes.service.ts b/src/modules/agent/agent-notes.service.ts new file mode 100644 index 0000000..abecaf3 --- /dev/null +++ b/src/modules/agent/agent-notes.service.ts @@ -0,0 +1,43 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AgentNotesRepository } from './repositories/agent-notes.repository'; + +@Injectable() +export class AgentNotesService { + constructor(private readonly agentNotesRepository: AgentNotesRepository) {} + + async listNotes(userId: number) { + this.assertUserId(userId); + const result = await this.agentNotesRepository.listNotes(userId); + + return result.rows; + } + + async createNote(userId: number, text: string) { + this.assertUserId(userId); + const normalizedText = String(text || '').trim(); + if (!normalizedText) { + throw new BadRequestException('Texto da nota e obrigatorio.'); + } + + const result = await this.agentNotesRepository.createNote(userId, normalizedText); + + return result.rows[0]; + } + + async deleteNote(userId: number, noteId: number) { + this.assertUserId(userId); + if (!Number.isFinite(noteId) || noteId <= 0) { + throw new BadRequestException('Nota invalida para exclusao.'); + } + + await this.agentNotesRepository.deleteNote(userId, noteId); + + return { ok: true }; + } + + private assertUserId(userId: number) { + if (!Number.isFinite(userId) || userId <= 0) { + throw new BadRequestException('Usuario invalido para notas do agente.'); + } + } +} diff --git a/src/modules/agent/agent-presence.controller.ts b/src/modules/agent/agent-presence.controller.ts new file mode 100644 index 0000000..6e67388 --- /dev/null +++ b/src/modules/agent/agent-presence.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { AgentPresenceService } from './agent-presence.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { AuthTokenPayload } from '../auth/auth-token.service'; +import { AgentPresenceUserDto } from './dto/agent.dto'; + +@ApiTags('Presenca do agente') +@Controller('agent/presence') +export class AgentPresenceController { + constructor(private readonly agentPresenceService: AgentPresenceService) {} + + @ApiOperation({ + summary: 'Lista presenca dos agentes', + description: 'Retorna o estado atual dos agentes para acompanhamento operacional, incluindo disponibilidade e pausas.', + }) + @Roles('Admin', 'Supervisor') + @Get() + listPresence() { + return this.agentPresenceService.listPresence(); + } + + @ApiOperation({ + summary: 'Consulta presenca do agente logado', + description: 'Retorna o status de presenca de um usuario especifico.', + }) + @Roles('Admin', 'Supervisor', 'Agente') + @ApiQuery({ name: 'userId', required: false, description: 'Mantido por compatibilidade; o backend usa o usuario autenticado no JWT.' }) + @Get('me') + getPresence(@CurrentUser() user: AuthTokenPayload, @Query('userId') _userId?: string) { + return this.agentPresenceService.getPresence(this.currentUserId(user)); + } + + @ApiOperation({ + summary: 'Coloca agente em pausa', + description: 'Marca o agente como pausado para que ele nao receba novos atendimentos enquanto estiver indisponivel.', + }) + @Roles('Admin', 'Supervisor', 'Agente') + @ApiBody({ schema: { type: 'object', properties: { userId: { type: 'number', example: 10, description: 'Mantido por compatibilidade; ignorado pelo backend.' } } } }) + @Post('pause') + pause(@CurrentUser() user: AuthTokenPayload, @Body() _body: AgentPresenceUserDto) { + return this.agentPresenceService.pause(this.currentUserId(user)); + } + + @ApiOperation({ + summary: 'Retoma disponibilidade do agente', + description: 'Remove a pausa do agente e marca o usuario como disponivel novamente.', + }) + @Roles('Admin', 'Supervisor', 'Agente') + @ApiBody({ schema: { type: 'object', properties: { userId: { type: 'number', example: 10, description: 'Mantido por compatibilidade; ignorado pelo backend.' } } } }) + @Post('resume') + resume(@CurrentUser() user: AuthTokenPayload, @Body() _body: AgentPresenceUserDto) { + return this.agentPresenceService.resume(this.currentUserId(user)); + } + + @ApiOperation({ + summary: 'Marca agente como offline', + description: 'Atualiza a presenca do agente para offline, usado em saida do painel ou encerramento de expediente.', + }) + @Roles('Admin', 'Supervisor', 'Agente') + @ApiBody({ schema: { type: 'object', properties: { userId: { type: 'number', example: 10, description: 'Mantido por compatibilidade; ignorado pelo backend.' } } } }) + @Post('offline') + offline(@CurrentUser() user: AuthTokenPayload, @Body() _body: AgentPresenceUserDto) { + return this.agentPresenceService.offline(this.currentUserId(user)); + } + + private currentUserId(user: AuthTokenPayload) { + return Number(user.sub); + } +} diff --git a/src/modules/agent/agent-presence.service.ts b/src/modules/agent/agent-presence.service.ts new file mode 100644 index 0000000..98442a6 --- /dev/null +++ b/src/modules/agent/agent-presence.service.ts @@ -0,0 +1,86 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AgentPresenceRepository } from './repositories/agent-presence.repository'; + +type AgentPresenceStatus = 'available' | 'paused' | 'offline'; + +@Injectable() +export class AgentPresenceService { + constructor(private readonly agentPresenceRepository: AgentPresenceRepository) {} + + async listPresence() { + const result = await this.agentPresenceRepository.listPresence(); + + return result.rows; + } + + async getPresence(userId: number) { + this.assertUserId(userId); + await this.touchPresence(userId, 'available', false); + + const result = await this.agentPresenceRepository.getPresence(userId); + + return result.rows[0]; + } + + async pause(userId: number) { + this.assertUserId(userId); + + return this.agentPresenceRepository.transaction(async (client) => { + const presence = await this.agentPresenceRepository.pausePresence(client, userId); + const released = await this.agentPresenceRepository.releaseAssignedChatsForPause(client, userId); + + return { + presence: presence.rows[0], + releasedCount: released.rowCount, + releasedChats: released.rows, + }; + }); + } + + async resume(userId: number) { + this.assertUserId(userId); + + return this.agentPresenceRepository.transaction(async (client) => { + const presence = await this.agentPresenceRepository.resumePresence(client, userId); + const restored = await this.agentPresenceRepository.restoreReservedChats(client, userId); + + return { + presence: presence.rows[0], + restoredCount: restored.rowCount, + restoredChats: restored.rows, + }; + }); + } + + async offline(userId: number) { + this.assertUserId(userId); + + return this.agentPresenceRepository.transaction(async (client) => { + const presence = await this.agentPresenceRepository.offlinePresence(client, userId); + const released = await this.agentPresenceRepository.releaseAssignedOrReservedChats(client, userId); + + return { + presence: presence.rows[0], + releasedCount: released.rowCount, + releasedChats: released.rows, + }; + }); + } + + async isAvailable(userId: number) { + this.assertUserId(userId); + const result = await this.agentPresenceRepository.getAvailability(userId); + + return result.rows[0]?.status === 'available'; + } + + private async touchPresence(userId: number, fallbackStatus: AgentPresenceStatus, updateStatus = true) { + await this.agentPresenceRepository.touchPresence(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/agent/agent.module.ts b/src/modules/agent/agent.module.ts new file mode 100644 index 0000000..4f6f4a0 --- /dev/null +++ b/src/modules/agent/agent.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +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 { AgentNotesRepository } from './repositories/agent-notes.repository'; +import { AgentPresenceRepository } from './repositories/agent-presence.repository'; + +@Module({ + controllers: [AgentNotesController, AgentPresenceController], + providers: [ + AgentNotesService, + AgentNotesRepository, + AgentPresenceService, + AgentPresenceRepository, + ], + exports: [AgentPresenceService], +}) +export class AgentModule {} diff --git a/src/modules/agent/dto/agent.dto.ts b/src/modules/agent/dto/agent.dto.ts new file mode 100644 index 0000000..65a38cf --- /dev/null +++ b/src/modules/agent/dto/agent.dto.ts @@ -0,0 +1,20 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class AgentPresenceUserDto { + @IsOptional() + @Type(() => Number) + @IsInt() + userId?: number; +} + +export class CreateAgentNoteDto { + @IsOptional() + @Type(() => Number) + @IsInt() + userId?: number; + + @IsString() + @IsNotEmpty() + text: string; +} diff --git a/src/modules/agent/repositories/agent-notes.repository.ts b/src/modules/agent/repositories/agent-notes.repository.ts new file mode 100644 index 0000000..e2cd275 --- /dev/null +++ b/src/modules/agent/repositories/agent-notes.repository.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../../../infra/database/database.service'; + +@Injectable() +export class AgentNotesRepository { + constructor(private readonly database: DatabaseService) {} + + listNotes(userId: number) { + return this.database.query( + ` + SELECT id, user_id, text, created_at + FROM agent_notes + WHERE user_id = $1 + ORDER BY created_at DESC + `, + [userId], + ); + } + + createNote(userId: number, text: string) { + return this.database.query( + ` + INSERT INTO agent_notes (user_id, text) + VALUES ($1, $2) + RETURNING id, user_id, text, created_at + `, + [userId, text], + ); + } + + deleteNote(userId: number, noteId: number) { + return this.database.query( + ` + DELETE FROM agent_notes + WHERE id = $1 AND user_id = $2 + `, + [noteId, userId], + ); + } +} diff --git a/src/modules/agent/repositories/agent-presence.repository.ts b/src/modules/agent/repositories/agent-presence.repository.ts new file mode 100644 index 0000000..630020c --- /dev/null +++ b/src/modules/agent/repositories/agent-presence.repository.ts @@ -0,0 +1,202 @@ +import { Injectable } from '@nestjs/common'; +import { PoolClient } from 'pg'; +import { DatabaseService } from '../../../infra/database/database.service'; + +type AgentPresenceStatus = 'available' | 'paused' | 'offline'; + +@Injectable() +export class AgentPresenceRepository { + constructor(private readonly database: DatabaseService) {} + + listPresence() { + return 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 + `); + } + + getPresence(userId: number) { + return 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], + ); + } + + getAvailability(userId: number) { + return 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], + ); + } + + touchPresence(userId: number, fallbackStatus: AgentPresenceStatus, updateStatus: boolean) { + return 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], + ); + } + + transaction(handler: (client: PoolClient) => Promise) { + return this.database.transaction(handler); + } + + pausePresence(client: PoolClient, userId: number) { + return 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], + ); + } + + releaseAssignedChatsForPause(client: PoolClient, userId: number) { + return 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], + ); + } + + resumePresence(client: PoolClient, userId: number) { + return 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], + ); + } + + restoreReservedChats(client: PoolClient, userId: number) { + return 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], + ); + } + + offlinePresence(client: PoolClient, userId: number) { + return 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], + ); + } + + releaseAssignedOrReservedChats(client: PoolClient, userId: number) { + return 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], + ); + } +} diff --git a/src/modules/attendance/attendance-assignment.service.ts b/src/modules/attendance/attendance-assignment.service.ts index c509c39..28fb35d 100644 --- a/src/modules/attendance/attendance-assignment.service.ts +++ b/src/modules/attendance/attendance-assignment.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { AgentPresenceService } from '../admin/agent-presence.service'; +import { AgentPresenceService } from '../agent/agent-presence.service'; import { AttendanceAssignmentRepository } from './repositories/attendance-assignment.repository'; interface TransferInput { diff --git a/src/modules/attendance/attendance.module.ts b/src/modules/attendance/attendance.module.ts index f1447aa..646c6c1 100644 --- a/src/modules/attendance/attendance.module.ts +++ b/src/modules/attendance/attendance.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; -import { AdminModule } from '../admin/admin.module'; +import { AgentModule } from '../agent/agent.module'; import { AttendanceAssignmentService } from './attendance-assignment.service'; import { AttendanceAssignmentRepository } from './repositories/attendance-assignment.repository'; @Module({ - imports: [AdminModule], + imports: [AgentModule], providers: [AttendanceAssignmentService, AttendanceAssignmentRepository], exports: [AttendanceAssignmentService], })