REFACTOR: Modularizado agente e presença operacional
Some checks failed
Deploy Dev / deploy (push) Has been cancelled

- 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
This commit is contained in:
Rafael Alves Lopes 2026-06-01 16:16:48 -03:00
parent 9298f1a162
commit 41dc1d78b5
16 changed files with 550 additions and 436 deletions

View File

@ -4,6 +4,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { DatabaseModule } from './infra/database/database.module'; import { DatabaseModule } from './infra/database/database.module';
import { AdminModule } from './modules/admin/admin.module'; import { AdminModule } from './modules/admin/admin.module';
import { AgentModule } from './modules/agent/agent.module';
import { AttendanceModule } from './modules/attendance/attendance.module'; import { AttendanceModule } from './modules/attendance/attendance.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { ContactsModule } from './modules/contacts/contacts.module'; import { ContactsModule } from './modules/contacts/contacts.module';
@ -21,6 +22,7 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
DatabaseModule, DatabaseModule,
AuthModule, AuthModule,
AdminModule, AdminModule,
AgentModule,
AttendanceModule, AttendanceModule,
ContactsModule, ContactsModule,
KnowledgeBaseModule, KnowledgeBaseModule,

View File

@ -1,24 +1,15 @@
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 { AgentPresenceController } from './agent-presence.controller';
import { AgentPresenceService } from './agent-presence.service';
import { AdminAccessRepository } from './repositories/admin-access.repository'; import { AdminAccessRepository } from './repositories/admin-access.repository';
@Module({ @Module({
controllers: [ controllers: [
AdminAccessController, AdminAccessController,
AgentNotesController,
AgentPresenceController,
], ],
providers: [ providers: [
AdminAccessService, AdminAccessService,
AdminAccessRepository, AdminAccessRepository,
AgentNotesService,
AgentPresenceService,
], ],
exports: [AgentPresenceService],
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -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));
}
}

View File

@ -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 };
}
}

View File

@ -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));
}
}

View File

@ -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.');
}
}
}

View File

@ -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);
}
}

View File

@ -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.');
}
}
}

View File

@ -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);
}
}

View File

@ -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.');
}
}
}

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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],
);
}
}

View File

@ -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<T>(handler: (client: PoolClient) => Promise<T>) {
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],
);
}
}

View File

@ -1,5 +1,5 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; 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'; import { AttendanceAssignmentRepository } from './repositories/attendance-assignment.repository';
interface TransferInput { interface TransferInput {

View File

@ -1,10 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module'; import { AgentModule } from '../agent/agent.module';
import { AttendanceAssignmentService } from './attendance-assignment.service'; import { AttendanceAssignmentService } from './attendance-assignment.service';
import { AttendanceAssignmentRepository } from './repositories/attendance-assignment.repository'; import { AttendanceAssignmentRepository } from './repositories/attendance-assignment.repository';
@Module({ @Module({
imports: [AdminModule], imports: [AgentModule],
providers: [AttendanceAssignmentService, AttendanceAssignmentRepository], providers: [AttendanceAssignmentService, AttendanceAssignmentRepository],
exports: [AttendanceAssignmentService], exports: [AttendanceAssignmentService],
}) })