REFACTOR: Modularizado agente e presença operacional
Some checks failed
Deploy Dev / deploy (push) Has been cancelled
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:
parent
9298f1a162
commit
41dc1d78b5
@ -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,
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/modules/agent/agent-notes.controller.ts
Normal file
64
src/modules/agent/agent-notes.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
43
src/modules/agent/agent-notes.service.ts
Normal file
43
src/modules/agent/agent-notes.service.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/modules/agent/agent-presence.controller.ts
Normal file
71
src/modules/agent/agent-presence.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
86
src/modules/agent/agent-presence.service.ts
Normal file
86
src/modules/agent/agent-presence.service.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/modules/agent/agent.module.ts
Normal file
19
src/modules/agent/agent.module.ts
Normal 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 {}
|
||||
20
src/modules/agent/dto/agent.dto.ts
Normal file
20
src/modules/agent/dto/agent.dto.ts
Normal 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;
|
||||
}
|
||||
40
src/modules/agent/repositories/agent-notes.repository.ts
Normal file
40
src/modules/agent/repositories/agent-notes.repository.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
202
src/modules/agent/repositories/agent-presence.repository.ts
Normal file
202
src/modules/agent/repositories/agent-presence.repository.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user