From df51645e722c553233a999439f3c528dbf7c38e9 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Fri, 29 May 2026 16:21:11 -0300 Subject: [PATCH] REFACTOR: Reestruturado Admin access com auditoria JWT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Liberado /admin/access/options para Agente, Supervisor e Admin - Criados DTOs para validação dos payloads administrativos - Extraído SQL do AdminAccessService para AdminAccessRepository - Removida criação de schema em runtime do módulo Admin - Auditoria passa a usar usuário autenticado via JWT - Substituídos erros genéricos por exceptions HTTP - Atualizada documentação do módulo Admin/access --- src/modules/admin/admin-access.controller.ts | 66 +- src/modules/admin/admin-access.service.ts | 573 +++++------------- src/modules/admin/admin.module.ts | 2 + src/modules/admin/dto/admin-access.dto.ts | 124 ++++ .../repositories/admin-access.repository.ts | 465 ++++++++++++++ .../auth/decorators/current-user.decorator.ts | 9 + 6 files changed, 766 insertions(+), 473 deletions(-) create mode 100644 src/modules/admin/dto/admin-access.dto.ts create mode 100644 src/modules/admin/repositories/admin-access.repository.ts create mode 100644 src/modules/auth/decorators/current-user.decorator.ts diff --git a/src/modules/admin/admin-access.controller.ts b/src/modules/admin/admin-access.controller.ts index 1372251..08eb7bd 100644 --- a/src/modules/admin/admin-access.controller.ts +++ b/src/modules/admin/admin-access.controller.ts @@ -1,7 +1,15 @@ -import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AdminAccessService } from './admin-access.service'; import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AuthTokenPayload } from '../auth/auth-token.service'; +import { + CreateAiContentDto, + CreateAreaDto, + UpdateAreaDto, + UpdateUserAccessDto, +} from './dto/admin-access.dto'; @ApiTags('Administracao') @Controller('admin/access') @@ -13,7 +21,7 @@ export class AdminAccessController { description: 'Retorna dados auxiliares usados nos formularios administrativos, como perfis, areas e opcoes de acesso.', }) @ApiResponse({ status: 200, description: 'Opcoes administrativas retornadas.' }) - @Roles('Admin', 'Supervisor') + @Roles('Admin', 'Supervisor', 'Agente') @Get('options') getOptions() { return this.adminAccessService.getOptions(); @@ -69,8 +77,8 @@ export class AdminAccessController { @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) @Roles('Admin') @Get('ai-contents/:id/file') - getAiContentFile(@Param('id') id: string) { - return this.adminAccessService.getAiContentFile(Number(id)); + getAiContentFile(@Param('id', ParseIntPipe) id: number) { + return this.adminAccessService.getAiContentFile(id); } @ApiOperation({ @@ -88,23 +96,13 @@ export class AdminAccessController { fileSize: { type: 'number', nullable: true, example: 102400 }, contentBase64: { type: 'string', nullable: true, description: 'Arquivo em base64.' }, notes: { type: 'string', nullable: true, example: 'Regras internas de ferias.' }, - createdByUserId: { type: 'number', nullable: true, example: 10 }, }, }, }) @Roles('Admin') @Post('ai-contents') - createAiContent(@Body() body: { - title?: string; - areaId?: number | null; - filename?: string | null; - mimetype?: string | null; - fileSize?: number | null; - contentBase64?: string | null; - notes?: string | null; - createdByUserId?: number | null; - }) { - return this.adminAccessService.createAiContent(body); + createAiContent(@Body() body: CreateAiContentDto, @CurrentUser() user: AuthTokenPayload) { + return this.adminAccessService.createAiContent(body, user); } @ApiOperation({ @@ -114,8 +112,8 @@ export class AdminAccessController { @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) @Roles('Admin') @Delete('ai-contents/:id') - deleteAiContent(@Param('id') id: string) { - return this.adminAccessService.deleteAiContent(Number(id)); + deleteAiContent(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthTokenPayload) { + return this.adminAccessService.deleteAiContent(id, user); } @ApiOperation({ @@ -145,8 +143,8 @@ export class AdminAccessController { }) @Roles('Admin') @Post('areas') - createArea(@Body() body: { nome: string; descricao?: string | null; responsavelUsuarioId?: number | null }) { - return this.adminAccessService.createArea(body); + createArea(@Body() body: CreateAreaDto, @CurrentUser() user: AuthTokenPayload) { + return this.adminAccessService.createArea(body, user); } @ApiOperation({ @@ -157,10 +155,11 @@ export class AdminAccessController { @Roles('Admin') @Put('areas/:id') updateArea( - @Param('id') id: string, - @Body() body: { nome?: string; descricao?: string | null; responsavelUsuarioId?: number | null; ativo?: boolean }, + @Param('id', ParseIntPipe) id: number, + @Body() body: UpdateAreaDto, + @CurrentUser() user: AuthTokenPayload, ) { - return this.adminAccessService.updateArea(Number(id), body); + return this.adminAccessService.updateArea(id, body, user); } @ApiOperation({ @@ -170,8 +169,8 @@ export class AdminAccessController { @ApiParam({ name: 'id', description: 'ID da area.' }) @Roles('Admin') @Delete('areas/:id') - deleteArea(@Param('id') id: string) { - return this.adminAccessService.deleteArea(Number(id)); + deleteArea(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthTokenPayload) { + return this.adminAccessService.deleteArea(id, user); } @ApiOperation({ @@ -215,19 +214,10 @@ export class AdminAccessController { @Roles('Admin') @Put('users/:id') updateUserAccess( - @Param('id') id: string, - @Body() body: { - perfilId?: number | null; - perfilIds?: number[]; - areaId?: number | null; - especialidades?: Array<{ - areaId: number; - funcao?: string | null; - principal?: boolean; - ativo?: boolean; - }>; - }, + @Param('id', ParseIntPipe) id: number, + @Body() body: UpdateUserAccessDto, + @CurrentUser() user: AuthTokenPayload, ) { - return this.adminAccessService.updateUserAccess(Number(id), body); + return this.adminAccessService.updateUserAccess(id, body, user); } } diff --git a/src/modules/admin/admin-access.service.ts b/src/modules/admin/admin-access.service.ts index 78c509c..e808d33 100644 --- a/src/modules/admin/admin-access.service.ts +++ b/src/modules/admin/admin-access.service.ts @@ -1,84 +1,26 @@ -import { Injectable } from '@nestjs/common'; -import { DatabaseService } from '../../infra/database/database.service'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { AuthTokenPayload } from '../auth/auth-token.service'; +import { + CreateAiContentDto, + CreateAreaDto, + UpdateAreaDto, + UpdateUserAccessDto, +} from './dto/admin-access.dto'; +import { AdminAccessRepository } from './repositories/admin-access.repository'; -interface AccessUpdateInput { - perfilId?: number | null; - perfilIds?: number[]; - areaId?: number | null; - especialidades?: Array<{ - areaId: number; - funcao?: string | null; - principal?: boolean; - ativo?: boolean; - }>; -} - -interface AreaInput { - nome?: string; - descricao?: string | null; - responsavelUsuarioId?: number | null; - ativo?: boolean; -} - -interface AiContentInput { - title?: string; - areaId?: number | null; - filename?: string | null; - mimetype?: string | null; - fileSize?: number | null; - contentBase64?: string | null; - notes?: string | null; - createdByUserId?: number | null; +interface AuditActor { + userId?: number | null; + name: string; } @Injectable() export class AdminAccessService { - constructor(private readonly database: DatabaseService) {} - - async onModuleInit() { - await this.ensureAdminSchema(); - } - - async ensureAdminSchema() { - await this.database.query(` - CREATE TABLE IF NOT EXISTS admin_audit_logs ( - id SERIAL PRIMARY KEY, - actor_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL, - actor_name VARCHAR(180), - action VARCHAR(120) NOT NULL, - target_type VARCHAR(80), - target_id VARCHAR(120), - details TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP - ); - `); - - await this.database.query(` - CREATE TABLE IF NOT EXISTS ai_knowledge_contents ( - id SERIAL PRIMARY KEY, - title VARCHAR(220) NOT NULL, - area_id INTEGER REFERENCES areas(id) ON DELETE SET NULL, - filename VARCHAR(260), - mimetype VARCHAR(160), - file_size INTEGER, - content_base64 TEXT, - status VARCHAR(40) NOT NULL DEFAULT 'available', - notes TEXT, - created_by_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP - ); - `); - } + constructor(private readonly adminAccessRepository: AdminAccessRepository) {} async getOptions() { const [profiles, areas] = await Promise.all([ - this.database.query<{ id: number; nome: string }>( - 'SELECT id, nome FROM perfis_acesso ORDER BY nome', - ), - this.database.query<{ id: number; nome: string }>( - 'SELECT id, nome FROM areas WHERE ativo = TRUE ORDER BY nome', - ), + this.adminAccessRepository.getProfiles(), + this.adminAccessRepository.getActiveAreasOptions(), ]); return { @@ -97,59 +39,13 @@ export class AdminAccessService { avgHandling, avgFirstResponse, ] = await Promise.all([ - this.database.query<{ total: string }>( - ` - SELECT COUNT(*)::TEXT AS total - FROM whatsapp_chat_atribuicoes - WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) - `, - ), - this.database.query<{ total: string }>( - ` - SELECT COUNT(*)::TEXT AS total - FROM whatsapp_chat_atribuicoes - WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' - AND conversation_started_at < date_trunc('month', CURRENT_DATE) - `, - ), - this.database.query<{ total: string }>( - ` - SELECT COUNT(DISTINCT user_id)::TEXT AS total - FROM whatsapp_chat_atribuicoes - WHERE user_id IS NOT NULL - AND conversation_started_at >= date_trunc('month', CURRENT_DATE) - `, - ), - this.database.query<{ total: string }>('SELECT COUNT(*)::TEXT AS total FROM usuarios WHERE ativo = TRUE'), - this.database.query<{ whatsapp: string; email: string; sms: string }>( - ` - SELECT - COUNT(*)::TEXT AS whatsapp, - '0'::TEXT AS email, - '0'::TEXT AS sms - FROM whatsapp_chat_atribuicoes - WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) - `, - ), - this.database.query<{ minutes: string | null }>( - ` - SELECT ROUND(AVG(EXTRACT(EPOCH FROM (updated_at - assigned_at))) / 60)::TEXT AS minutes - FROM whatsapp_chat_atribuicoes - WHERE assigned_at IS NOT NULL - AND updated_at IS NOT NULL - AND status IN ('assigned', 'queued', 'expired') - AND conversation_started_at >= date_trunc('month', CURRENT_DATE) - `, - ), - this.database.query<{ minutes: string | null }>( - ` - SELECT ROUND(AVG(EXTRACT(EPOCH FROM (assigned_at - conversation_started_at))) / 60)::TEXT AS minutes - FROM whatsapp_chat_atribuicoes - WHERE assigned_at IS NOT NULL - AND conversation_started_at IS NOT NULL - AND conversation_started_at >= date_trunc('month', CURRENT_DATE) - `, - ), + this.adminAccessRepository.getCurrentMonthAttendances(), + this.adminAccessRepository.getPreviousMonthAttendances(), + this.adminAccessRepository.getActiveUsersInCurrentMonth(), + this.adminAccessRepository.getTotalActiveUsers(), + this.adminAccessRepository.getChannelDistribution(), + this.adminAccessRepository.getAverageHandlingMinutes(), + this.adminAccessRepository.getAverageFirstResponseMinutes(), ]); const currentTotal = Number(currentMonth.rows[0]?.total || 0); @@ -175,31 +71,7 @@ export class AdminAccessService { } async getAttendantRanking(areaId?: number | null) { - const params: unknown[] = []; - const areaFilter = areaId ? 'AND wca.area_id = $1' : ''; - if (areaId) params.push(areaId); - - const result = await this.database.query( - ` - SELECT - u.id, - u.nome AS name, - COALESCE(a.nome, 'Sem especialidade') AS area, - COUNT(wca.id)::INTEGER AS closed, - COALESCE(ROUND(AVG(EXTRACT(EPOCH FROM (wca.updated_at - wca.assigned_at))) / 60)::INTEGER, 0) AS avg_minutes - FROM whatsapp_chat_atribuicoes wca - INNER JOIN usuarios u ON u.id = wca.user_id OR u.id = wca.reserved_user_id - LEFT JOIN areas a ON a.id = wca.area_id - WHERE wca.assigned_at IS NOT NULL - AND wca.conversation_started_at >= date_trunc('month', CURRENT_DATE) - AND wca.status IN ('expired', 'assigned') - ${areaFilter} - GROUP BY u.id, u.nome, a.nome - ORDER BY closed DESC, avg_minutes ASC - LIMIT 10 - `, - params, - ); + const result = await this.adminAccessRepository.getAttendantRanking(areaId); return result.rows.map((row: any) => ({ id: `${row.id}-${row.area}`, @@ -215,78 +87,7 @@ export class AdminAccessService { const safePage = Math.max(1, Number(page) || 1); const safeLimit = Math.min(100, Math.max(1, Number(limit) || 100)); const offset = (safePage - 1) * safeLimit; - - const result = await this.database.query( - ` - WITH events AS ( - SELECT - ('audit-' || id)::TEXT AS id, - created_at, - COALESCE(actor_name, 'Sistema') AS actor, - action, - COALESCE(target_type, 'Registro') AS target_type, - target_id, - details - FROM admin_audit_logs - - UNION ALL - - SELECT - ('area-' || id)::TEXT AS id, - updated_at AS created_at, - 'Admin' AS actor, - CASE WHEN ativo THEN 'Especialidade atualizada' ELSE 'Especialidade desativada' END AS action, - 'Especialidade' AS target_type, - id::TEXT AS target_id, - nome AS details - FROM areas - - UNION ALL - - SELECT - ('template-' || wt.id)::TEXT AS id, - wt.updated_at AS created_at, - COALESCE(wt.requested_by_role, 'Admin') AS actor, - CASE wt.status - WHEN 'approved' THEN 'Template aprovado' - WHEN 'rejected' THEN 'Template reprovado' - WHEN 'meta_review' THEN 'Template enviado para análise' - ELSE 'Template atualizado' - END AS action, - 'Template' AS target_type, - wt.id::TEXT AS target_id, - wt.name AS details - FROM whatsapp_templates wt - - UNION ALL - - SELECT - ('assignment-' || wca.id)::TEXT AS id, - wca.updated_at AS created_at, - COALESCE(u.nome, 'Sistema') AS actor, - CASE wca.status - WHEN 'queued' THEN 'Atendimento em fila' - WHEN 'assigned' THEN 'Atendimento atribuído' - WHEN 'expired' THEN 'Atendimento encerrado' - ELSE 'Atendimento atualizado' - END AS action, - 'Atendimento' AS target_type, - wca.chat_id AS target_id, - COALESCE(a.nome, wca.transfer_note, '') AS details - FROM whatsapp_chat_atribuicoes wca - LEFT JOIN usuarios u ON u.id = wca.user_id - LEFT JOIN areas a ON a.id = wca.area_id - ), - counted AS ( - SELECT COUNT(*)::INTEGER AS total FROM events - ) - SELECT events.*, counted.total - FROM events, counted - ORDER BY events.created_at DESC, events.id DESC - LIMIT $1 OFFSET $2 - `, - [safeLimit, offset], - ); + const result = await this.adminAccessRepository.listAuditLogs(safeLimit, offset); return { page: safePage, @@ -297,140 +98,67 @@ export class AdminAccessService { } async listAiContents() { - const result = await this.database.query( - ` - SELECT - c.id, - c.title, - c.area_id, - a.nome AS area_nome, - c.filename, - c.mimetype, - c.file_size, - c.status, - c.notes, - c.created_at, - c.updated_at - FROM ai_knowledge_contents c - LEFT JOIN areas a ON a.id = c.area_id - ORDER BY c.created_at DESC, c.id DESC - `, - ); - + const result = await this.adminAccessRepository.listAiContents(); return result.rows; } async getAiContentFile(id: number) { - const result = await this.database.query( - ` - SELECT id, title, filename, mimetype, content_base64 - FROM ai_knowledge_contents - WHERE id = $1 - `, - [id], - ); + const result = await this.adminAccessRepository.getAiContentFile(id); if (!result.rows[0]) { - throw new Error('Conteudo de IA nao encontrado'); + throw new NotFoundException('Conteúdo de IA não encontrado'); } return result.rows[0]; } - async createAiContent(input: AiContentInput) { - const title = String(input.title || '').trim(); + async createAiContent(input: CreateAiContentDto, user: AuthTokenPayload) { + const title = this.normalizeText(input.title); + if (!title) { - throw new Error('Titulo do conteudo e obrigatorio'); + throw new BadRequestException('Título do conteúdo é obrigatório'); } - const result = await this.database.query( - ` - INSERT INTO ai_knowledge_contents ( - title, area_id, filename, mimetype, file_size, content_base64, - status, notes, created_by_user_id, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, 'available', $7, $8, CURRENT_TIMESTAMP) - RETURNING * - `, - [ - title, - input.areaId || null, - this.normalizeText(input.filename), - this.normalizeText(input.mimetype), - input.fileSize || null, - input.contentBase64 || null, - this.normalizeText(input.notes), - input.createdByUserId || null, - ], - ); + const actor = this.getAuditActor(user); + const result = await this.adminAccessRepository.createAiContent({ + title, + areaId: input.areaId, + filename: this.normalizeText(input.filename), + mimetype: this.normalizeText(input.mimetype), + fileSize: input.fileSize, + contentBase64: input.contentBase64 || null, + notes: this.normalizeText(input.notes), + createdByUserId: actor.userId, + }); - await this.logAudit('Conteúdo de IA adicionado', 'Conteúdo IA', String(result.rows[0].id), title); + await this.logAudit(actor, 'Conteúdo de IA adicionado', 'Conteúdo IA', String(result.rows[0].id), title); return this.listAiContents(); } - async deleteAiContent(id: number) { - await this.database.query('DELETE FROM ai_knowledge_contents WHERE id = $1', [id]); - await this.logAudit('Conteúdo de IA removido', 'Conteúdo IA', String(id), 'Remoção de conteúdo'); + async deleteAiContent(id: number, user: AuthTokenPayload) { + const result = await this.adminAccessRepository.deleteAiContent(id); + + if (!result.rows[0]) { + throw new NotFoundException('Conteúdo de IA não encontrado'); + } + + await this.logAudit( + this.getAuditActor(user), + 'Conteúdo de IA removido', + 'Conteúdo IA', + String(id), + 'Remoção de conteúdo', + ); return this.listAiContents(); } async listAreas() { - const result = await this.database.query( - ` - SELECT - a.id, - a.nome, - a.descricao, - a.ativo, - a.responsavel_usuario_id, - r.nome AS responsavel_nome, - COALESCE( - JSON_AGG(DISTINCT JSONB_BUILD_OBJECT('id', su.id, 'nome', su.nome)) - FILTER (WHERE su.id IS NOT NULL), - '[]' - ) AS supervisores, - COUNT(DISTINCT ua.usuario_id)::INTEGER AS members - FROM areas a - LEFT JOIN usuarios r ON r.id = a.responsavel_usuario_id - LEFT JOIN usuarios_areas ua ON ua.area_id = a.id AND ua.ativo = TRUE - LEFT JOIN usuarios_areas sua ON sua.area_id = a.id AND sua.ativo = TRUE AND sua.funcao = 'Supervisor' - LEFT JOIN usuarios su ON su.id = sua.usuario_id - WHERE a.ativo = TRUE - GROUP BY a.id, r.nome - ORDER BY a.nome - `, - ); - + const result = await this.adminAccessRepository.listAreas(); return result.rows; } async listUsers() { - const result = await this.database.query( - ` - SELECT - u.id, - u.nome, - u.email, - u.ativo, - COALESCE( - JSON_AGG(DISTINCT JSONB_BUILD_OBJECT('id', p.id, 'nome', p.nome)) - FILTER (WHERE p.id IS NOT NULL), - '[]' - ) AS perfis, - COALESCE( - JSON_AGG(DISTINCT JSONB_BUILD_OBJECT('id', a.id, 'nome', a.nome, 'principal', ua.principal, 'funcao', ua.funcao)) - FILTER (WHERE a.id IS NOT NULL AND ua.ativo = TRUE), - '[]' - ) AS areas - FROM usuarios u - LEFT JOIN usuarios_perfis up ON up.usuario_id = u.id - LEFT JOIN perfis_acesso p ON p.id = up.perfil_id - LEFT JOIN usuarios_areas ua ON ua.usuario_id = u.id - LEFT JOIN areas a ON a.id = ua.area_id - GROUP BY u.id - ORDER BY u.nome - `, - ); + const result = await this.adminAccessRepository.listUsers(); return result.rows.map((user: any) => { const perfis = Array.isArray(user.perfis) ? user.perfis : []; @@ -452,10 +180,10 @@ export class AdminAccessService { }); } - async updateUserAccess(usuarioId: number, input: AccessUpdateInput) { - await this.database.transaction(async (client) => { - await client.query('DELETE FROM usuarios_perfis WHERE usuario_id = $1', [usuarioId]); - await client.query('DELETE FROM usuarios_areas WHERE usuario_id = $1', [usuarioId]); + async updateUserAccess(usuarioId: number, input: UpdateUserAccessDto, user: AuthTokenPayload) { + await this.adminAccessRepository.transaction(async (client) => { + await this.adminAccessRepository.deleteUserProfiles(client, usuarioId); + await this.adminAccessRepository.deleteUserAreas(client, usuarioId); const perfilIds = Array.isArray(input.perfilIds) ? input.perfilIds @@ -464,10 +192,7 @@ export class AdminAccessService { : []; for (const perfilId of [...new Set(perfilIds.filter(Boolean))]) { - await client.query( - 'INSERT INTO usuarios_perfis (usuario_id, perfil_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', - [usuarioId, perfilId], - ); + await this.adminAccessRepository.insertUserProfile(client, usuarioId, perfilId); } const especialidades = Array.isArray(input.especialidades) @@ -484,141 +209,119 @@ export class AdminAccessService { const isPrincipal = Boolean(item.principal) && !hasPrincipal; hasPrincipal = hasPrincipal || isPrincipal; - await client.query( - ` - INSERT INTO usuarios_areas (usuario_id, area_id, funcao, principal, ativo) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (usuario_id, area_id) - DO UPDATE SET funcao = EXCLUDED.funcao, principal = EXCLUDED.principal, ativo = EXCLUDED.ativo, updated_at = NOW() - `, - [usuarioId, areaId, this.normalizeRole(item.funcao), isPrincipal, item.ativo !== false], + await this.adminAccessRepository.upsertUserArea( + client, + usuarioId, + areaId, + this.normalizeRole(item.funcao), + isPrincipal, + item.ativo !== false, ); } }); - await this.logAudit('Acesso de usuário atualizado', 'Usuário', String(usuarioId), 'Perfis e especialidades alterados'); - return this.listUsers().then((users) => users.find((user) => user.id === usuarioId)); + await this.logAudit( + this.getAuditActor(user), + 'Acesso de usuário atualizado', + 'Usuário', + String(usuarioId), + 'Perfis e especialidades alterados', + ); + return this.listUsers().then((users) => users.find((item) => item.id === usuarioId)); } - async createArea(input: AreaInput) { - const nome = String(input.nome || '').trim(); + async createArea(input: CreateAreaDto, user: AuthTokenPayload) { + const nome = this.normalizeText(input.nome); + if (!nome) { - throw new Error('Nome da area e obrigatorio'); + throw new BadRequestException('Nome da área é obrigatório'); } - const result = await this.database.query( - ` - INSERT INTO areas (nome, descricao, responsavel_usuario_id, ativo, created_at, updated_at) - VALUES ($1, $2, $3, TRUE, NOW(), NOW()) - ON CONFLICT (nome) DO UPDATE SET - descricao = EXCLUDED.descricao, - responsavel_usuario_id = EXCLUDED.responsavel_usuario_id, - ativo = TRUE, - updated_at = NOW() - RETURNING * - `, - [nome, this.normalizeText(input.descricao), input.responsavelUsuarioId || null], - ); + const result = await this.adminAccessRepository.createArea({ + nome, + descricao: this.normalizeText(input.descricao), + responsavelUsuarioId: input.responsavelUsuarioId, + }); if (input.responsavelUsuarioId) { await this.ensureAreaSupervisor(input.responsavelUsuarioId, result.rows[0].id); } - await this.logAudit('Especialidade criada', 'Especialidade', String(result.rows[0].id), nome); + await this.logAudit(this.getAuditActor(user), 'Especialidade criada', 'Especialidade', String(result.rows[0].id), nome); return this.listAreas(); } - async updateArea(areaId: number, input: AreaInput) { - const result = await this.database.query( - ` - UPDATE areas - SET - nome = COALESCE($2, nome), - descricao = $3, - responsavel_usuario_id = $4, - ativo = COALESCE($5, ativo), - updated_at = NOW() - WHERE id = $1 - RETURNING * - `, - [ - areaId, - input.nome ? String(input.nome).trim() : null, - this.normalizeText(input.descricao), - input.responsavelUsuarioId || null, - typeof input.ativo === 'boolean' ? input.ativo : null, - ], - ); + async updateArea(areaId: number, input: UpdateAreaDto, user: AuthTokenPayload) { + const result = await this.adminAccessRepository.updateArea(areaId, { + nome: this.normalizeText(input.nome), + descricao: this.normalizeText(input.descricao), + responsavelUsuarioId: input.responsavelUsuarioId, + ativo: input.ativo, + }); - if (input.responsavelUsuarioId && result.rows[0]) { + if (!result.rows[0]) { + throw new NotFoundException('Área não encontrada'); + } + + if (input.responsavelUsuarioId) { await this.ensureAreaSupervisor(input.responsavelUsuarioId, areaId); } - await this.logAudit('Especialidade atualizada', 'Especialidade', String(areaId), input.nome || result.rows[0]?.nome || ''); + await this.logAudit( + this.getAuditActor(user), + 'Especialidade atualizada', + 'Especialidade', + String(areaId), + input.nome || result.rows[0]?.nome || '', + ); return this.listAreas(); } - async deleteArea(areaId: number) { - await this.database.query( - ` - UPDATE areas - SET ativo = FALSE, - updated_at = NOW() - WHERE id = $1 - `, - [areaId], + async deleteArea(areaId: number, user: AuthTokenPayload) { + await this.adminAccessRepository.deactivateArea(areaId); + await this.logAudit( + this.getAuditActor(user), + 'Especialidade desativada', + 'Especialidade', + String(areaId), + 'Especialidade removida da operação', ); - - await this.database.query( - ` - UPDATE usuarios_areas - SET ativo = FALSE, - updated_at = NOW() - WHERE area_id = $1 - `, - [areaId], - ); - - await this.logAudit('Especialidade desativada', 'Especialidade', String(areaId), 'Especialidade removida da operação'); return this.listAreas(); } - private async logAudit(action: string, targetType: string, targetId: string, details?: string | null) { - await this.database.query( - ` - INSERT INTO admin_audit_logs (actor_name, action, target_type, target_id, details) - VALUES ('Admin', $1, $2, $3, $4) - `, - [action, targetType, targetId, details || null], - ).catch(() => undefined); + private async logAudit(actor: AuditActor, action: string, targetType: string, targetId: string, details?: string | null) { + await this.adminAccessRepository + .insertAuditLog({ + actorUserId: actor.userId, + actorName: actor.name, + action, + targetType, + targetId, + details, + }) + .catch(() => undefined); } private async ensureAreaSupervisor(usuarioId: number, areaId: number) { - const supervisorProfile = await this.database.query<{ id: number }>( - `SELECT id FROM perfis_acesso WHERE nome = 'Supervisor' LIMIT 1`, - ); + const supervisorProfile = await this.adminAccessRepository.getSupervisorProfile(); const perfilId = supervisorProfile.rows[0]?.id; - await this.database.transaction(async (client) => { + await this.adminAccessRepository.transaction(async (client) => { if (perfilId) { - await client.query( - `INSERT INTO usuarios_perfis (usuario_id, perfil_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, - [usuarioId, perfilId], - ); + await this.adminAccessRepository.insertUserProfile(client, usuarioId, perfilId); } - await client.query( - ` - INSERT INTO usuarios_areas (usuario_id, area_id, funcao, principal, ativo) - VALUES ($1, $2, 'Supervisor', TRUE, TRUE) - ON CONFLICT (usuario_id, area_id) - DO UPDATE SET funcao = 'Supervisor', principal = TRUE, ativo = TRUE, updated_at = NOW() - `, - [usuarioId, areaId], - ); + await this.adminAccessRepository.upsertUserArea(client, usuarioId, areaId, 'Supervisor', true, true); }); } + private getAuditActor(user: AuthTokenPayload): AuditActor { + return { + userId: user.sub ? Number(user.sub) : null, + name: user.name || user.username || 'Usuário autenticado', + }; + } + private normalizeText(value?: string | null) { const text = String(value || '').trim(); return text || null; diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 86f308f..94681ac 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -9,6 +9,7 @@ import { CustomerContactsController } from './customer-contacts.controller'; import { CustomerContactsService } from './customer-contacts.service'; import { KnowledgeBaseController } from './knowledge-base.controller'; import { KnowledgeBaseService } from './knowledge-base.service'; +import { AdminAccessRepository } from './repositories/admin-access.repository'; @Module({ controllers: [ @@ -20,6 +21,7 @@ import { KnowledgeBaseService } from './knowledge-base.service'; ], providers: [ AdminAccessService, + AdminAccessRepository, AgentNotesService, AgentPresenceService, CustomerContactsService, diff --git a/src/modules/admin/dto/admin-access.dto.ts b/src/modules/admin/dto/admin-access.dto.ts new file mode 100644 index 0000000..5579a39 --- /dev/null +++ b/src/modules/admin/dto/admin-access.dto.ts @@ -0,0 +1,124 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; + +export class CreateAiContentDto { + @IsString() + @IsNotEmpty() + @MaxLength(220) + title: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + areaId?: number | null; + + @IsOptional() + @IsString() + @MaxLength(260) + filename?: string | null; + + @IsOptional() + @IsString() + @MaxLength(160) + mimetype?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + fileSize?: number | null; + + @IsOptional() + @IsString() + contentBase64?: string | null; + + @IsOptional() + @IsString() + notes?: string | null; +} + +export class CreateAreaDto { + @IsString() + @IsNotEmpty() + @MaxLength(120) + nome: string; + + @IsOptional() + @IsString() + descricao?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + responsavelUsuarioId?: number | null; +} + +export class UpdateAreaDto { + @IsOptional() + @IsString() + @MaxLength(120) + nome?: string; + + @IsOptional() + @IsString() + descricao?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + responsavelUsuarioId?: number | null; + + @IsOptional() + @IsBoolean() + ativo?: boolean; +} + +export class UserSpecialtyDto { + @Type(() => Number) + @IsInt() + areaId: number; + + @IsOptional() + @IsString() + funcao?: string | null; + + @IsOptional() + @IsBoolean() + principal?: boolean; + + @IsOptional() + @IsBoolean() + ativo?: boolean; +} + +export class UpdateUserAccessDto { + @IsOptional() + @Type(() => Number) + @IsInt() + perfilId?: number | null; + + @IsOptional() + @IsArray() + @Type(() => Number) + @IsInt({ each: true }) + perfilIds?: number[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + areaId?: number | null; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UserSpecialtyDto) + especialidades?: UserSpecialtyDto[]; +} diff --git a/src/modules/admin/repositories/admin-access.repository.ts b/src/modules/admin/repositories/admin-access.repository.ts new file mode 100644 index 0000000..5a93e87 --- /dev/null +++ b/src/modules/admin/repositories/admin-access.repository.ts @@ -0,0 +1,465 @@ +import { Injectable } from '@nestjs/common'; +import { PoolClient } from 'pg'; +import { DatabaseService } from '../../../infra/database/database.service'; + +interface AreaPersistenceInput { + nome?: string | null; + descricao?: string | null; + responsavelUsuarioId?: number | null; + ativo?: boolean; +} + +interface AiContentPersistenceInput { + title: string; + areaId?: number | null; + filename?: string | null; + mimetype?: string | null; + fileSize?: number | null; + contentBase64?: string | null; + notes?: string | null; + createdByUserId?: number | null; +} + +interface AuditInput { + actorUserId?: number | null; + actorName: string; + action: string; + targetType: string; + targetId: string; + details?: string | null; +} + +@Injectable() +export class AdminAccessRepository { + constructor(private readonly database: DatabaseService) {} + + getProfiles() { + return this.database.query<{ id: number; nome: string }>( + 'SELECT id, nome FROM perfis_acesso ORDER BY nome', + ); + } + + getActiveAreasOptions() { + return this.database.query<{ id: number; nome: string }>( + 'SELECT id, nome FROM areas WHERE ativo = TRUE ORDER BY nome', + ); + } + + getCurrentMonthAttendances() { + return this.database.query<{ total: string }>( + ` + SELECT COUNT(*)::TEXT AS total + FROM whatsapp_chat_atribuicoes + WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ); + } + + getPreviousMonthAttendances() { + return this.database.query<{ total: string }>( + ` + SELECT COUNT(*)::TEXT AS total + FROM whatsapp_chat_atribuicoes + WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' + AND conversation_started_at < date_trunc('month', CURRENT_DATE) + `, + ); + } + + getActiveUsersInCurrentMonth() { + return this.database.query<{ total: string }>( + ` + SELECT COUNT(DISTINCT user_id)::TEXT AS total + FROM whatsapp_chat_atribuicoes + WHERE user_id IS NOT NULL + AND conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ); + } + + getTotalActiveUsers() { + return this.database.query<{ total: string }>( + 'SELECT COUNT(*)::TEXT AS total FROM usuarios WHERE ativo = TRUE', + ); + } + + getChannelDistribution() { + return this.database.query<{ whatsapp: string; email: string; sms: string }>( + ` + SELECT + COUNT(*)::TEXT AS whatsapp, + '0'::TEXT AS email, + '0'::TEXT AS sms + FROM whatsapp_chat_atribuicoes + WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ); + } + + getAverageHandlingMinutes() { + return this.database.query<{ minutes: string | null }>( + ` + SELECT ROUND(AVG(EXTRACT(EPOCH FROM (updated_at - assigned_at))) / 60)::TEXT AS minutes + FROM whatsapp_chat_atribuicoes + WHERE assigned_at IS NOT NULL + AND updated_at IS NOT NULL + AND status IN ('assigned', 'queued', 'expired') + AND conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ); + } + + getAverageFirstResponseMinutes() { + return this.database.query<{ minutes: string | null }>( + ` + SELECT ROUND(AVG(EXTRACT(EPOCH FROM (assigned_at - conversation_started_at))) / 60)::TEXT AS minutes + FROM whatsapp_chat_atribuicoes + WHERE assigned_at IS NOT NULL + AND conversation_started_at IS NOT NULL + AND conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ); + } + + getAttendantRanking(areaId?: number | null) { + const params: unknown[] = []; + const areaFilter = areaId ? 'AND wca.area_id = $1' : ''; + if (areaId) params.push(areaId); + + return this.database.query( + ` + SELECT + u.id, + u.nome AS name, + COALESCE(a.nome, 'Sem especialidade') AS area, + COUNT(wca.id)::INTEGER AS closed, + COALESCE(ROUND(AVG(EXTRACT(EPOCH FROM (wca.updated_at - wca.assigned_at))) / 60)::INTEGER, 0) AS avg_minutes + FROM whatsapp_chat_atribuicoes wca + INNER JOIN usuarios u ON u.id = wca.user_id OR u.id = wca.reserved_user_id + LEFT JOIN areas a ON a.id = wca.area_id + WHERE wca.assigned_at IS NOT NULL + AND wca.conversation_started_at >= date_trunc('month', CURRENT_DATE) + AND wca.status IN ('expired', 'assigned') + ${areaFilter} + GROUP BY u.id, u.nome, a.nome + ORDER BY closed DESC, avg_minutes ASC + LIMIT 10 + `, + params, + ); + } + + listAuditLogs(limit: number, offset: number) { + return this.database.query( + ` + WITH events AS ( + SELECT + ('audit-' || id)::TEXT AS id, + created_at, + COALESCE(actor_name, 'Sistema') AS actor, + action, + COALESCE(target_type, 'Registro') AS target_type, + target_id, + details + FROM admin_audit_logs + + UNION ALL + + SELECT + ('area-' || id)::TEXT AS id, + updated_at AS created_at, + 'Admin' AS actor, + CASE WHEN ativo THEN 'Especialidade atualizada' ELSE 'Especialidade desativada' END AS action, + 'Especialidade' AS target_type, + id::TEXT AS target_id, + nome AS details + FROM areas + + UNION ALL + + SELECT + ('template-' || wt.id)::TEXT AS id, + wt.updated_at AS created_at, + COALESCE(wt.requested_by_role, 'Admin') AS actor, + CASE wt.status + WHEN 'approved' THEN 'Template aprovado' + WHEN 'rejected' THEN 'Template reprovado' + WHEN 'meta_review' THEN 'Template enviado para análise' + ELSE 'Template atualizado' + END AS action, + 'Template' AS target_type, + wt.id::TEXT AS target_id, + wt.name AS details + FROM whatsapp_templates wt + + UNION ALL + + SELECT + ('assignment-' || wca.id)::TEXT AS id, + wca.updated_at AS created_at, + COALESCE(u.nome, 'Sistema') AS actor, + CASE wca.status + WHEN 'queued' THEN 'Atendimento em fila' + WHEN 'assigned' THEN 'Atendimento atribuído' + WHEN 'expired' THEN 'Atendimento encerrado' + ELSE 'Atendimento atualizado' + END AS action, + 'Atendimento' AS target_type, + wca.chat_id AS target_id, + COALESCE(a.nome, wca.transfer_note, '') AS details + FROM whatsapp_chat_atribuicoes wca + LEFT JOIN usuarios u ON u.id = wca.user_id + LEFT JOIN areas a ON a.id = wca.area_id + ), + counted AS ( + SELECT COUNT(*)::INTEGER AS total FROM events + ) + SELECT events.*, counted.total + FROM events, counted + ORDER BY events.created_at DESC, events.id DESC + LIMIT $1 OFFSET $2 + `, + [limit, offset], + ); + } + + listAiContents() { + return this.database.query( + ` + SELECT + c.id, + c.title, + c.area_id, + a.nome AS area_nome, + c.filename, + c.mimetype, + c.file_size, + c.status, + c.notes, + c.created_at, + c.updated_at + FROM ai_knowledge_contents c + LEFT JOIN areas a ON a.id = c.area_id + ORDER BY c.created_at DESC, c.id DESC + `, + ); + } + + getAiContentFile(id: number) { + return this.database.query( + ` + SELECT id, title, filename, mimetype, content_base64 + FROM ai_knowledge_contents + WHERE id = $1 + `, + [id], + ); + } + + createAiContent(input: AiContentPersistenceInput) { + return this.database.query( + ` + INSERT INTO ai_knowledge_contents ( + title, area_id, filename, mimetype, file_size, content_base64, + status, notes, created_by_user_id, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, 'available', $7, $8, CURRENT_TIMESTAMP) + RETURNING * + `, + [ + input.title, + input.areaId || null, + input.filename || null, + input.mimetype || null, + input.fileSize || null, + input.contentBase64 || null, + input.notes || null, + input.createdByUserId || null, + ], + ); + } + + deleteAiContent(id: number) { + return this.database.query('DELETE FROM ai_knowledge_contents WHERE id = $1 RETURNING id', [id]); + } + + listAreas() { + return this.database.query( + ` + SELECT + a.id, + a.nome, + a.descricao, + a.ativo, + a.responsavel_usuario_id, + r.nome AS responsavel_nome, + COALESCE( + JSON_AGG(DISTINCT JSONB_BUILD_OBJECT('id', su.id, 'nome', su.nome)) + FILTER (WHERE su.id IS NOT NULL), + '[]' + ) AS supervisores, + COUNT(DISTINCT ua.usuario_id)::INTEGER AS members + FROM areas a + LEFT JOIN usuarios r ON r.id = a.responsavel_usuario_id + LEFT JOIN usuarios_areas ua ON ua.area_id = a.id AND ua.ativo = TRUE + LEFT JOIN usuarios_areas sua ON sua.area_id = a.id AND sua.ativo = TRUE AND sua.funcao = 'Supervisor' + LEFT JOIN usuarios su ON su.id = sua.usuario_id + WHERE a.ativo = TRUE + GROUP BY a.id, r.nome + ORDER BY a.nome + `, + ); + } + + listUsers() { + return this.database.query( + ` + SELECT + u.id, + u.nome, + u.email, + u.ativo, + COALESCE( + JSON_AGG(DISTINCT JSONB_BUILD_OBJECT('id', p.id, 'nome', p.nome)) + FILTER (WHERE p.id IS NOT NULL), + '[]' + ) AS perfis, + COALESCE( + JSON_AGG(DISTINCT JSONB_BUILD_OBJECT('id', a.id, 'nome', a.nome, 'principal', ua.principal, 'funcao', ua.funcao)) + FILTER (WHERE a.id IS NOT NULL AND ua.ativo = TRUE), + '[]' + ) AS areas + FROM usuarios u + LEFT JOIN usuarios_perfis up ON up.usuario_id = u.id + LEFT JOIN perfis_acesso p ON p.id = up.perfil_id + LEFT JOIN usuarios_areas ua ON ua.usuario_id = u.id + LEFT JOIN areas a ON a.id = ua.area_id + GROUP BY u.id + ORDER BY u.nome + `, + ); + } + + createArea(input: AreaPersistenceInput) { + return this.database.query( + ` + INSERT INTO areas (nome, descricao, responsavel_usuario_id, ativo, created_at, updated_at) + VALUES ($1, $2, $3, TRUE, NOW(), NOW()) + ON CONFLICT (nome) DO UPDATE SET + descricao = EXCLUDED.descricao, + responsavel_usuario_id = EXCLUDED.responsavel_usuario_id, + ativo = TRUE, + updated_at = NOW() + RETURNING * + `, + [input.nome, input.descricao || null, input.responsavelUsuarioId || null], + ); + } + + updateArea(areaId: number, input: AreaPersistenceInput) { + return this.database.query( + ` + UPDATE areas + SET + nome = COALESCE($2, nome), + descricao = $3, + responsavel_usuario_id = $4, + ativo = COALESCE($5, ativo), + updated_at = NOW() + WHERE id = $1 + RETURNING * + `, + [ + areaId, + input.nome || null, + input.descricao || null, + input.responsavelUsuarioId || null, + typeof input.ativo === 'boolean' ? input.ativo : null, + ], + ); + } + + async deactivateArea(areaId: number) { + await this.database.query( + ` + UPDATE areas + SET ativo = FALSE, + updated_at = NOW() + WHERE id = $1 + RETURNING id + `, + [areaId], + ); + + await this.database.query( + ` + UPDATE usuarios_areas + SET ativo = FALSE, + updated_at = NOW() + WHERE area_id = $1 + `, + [areaId], + ); + } + + transaction(handler: (client: PoolClient) => Promise) { + return this.database.transaction(handler); + } + + deleteUserProfiles(client: PoolClient, usuarioId: number) { + return client.query('DELETE FROM usuarios_perfis WHERE usuario_id = $1', [usuarioId]); + } + + deleteUserAreas(client: PoolClient, usuarioId: number) { + return client.query('DELETE FROM usuarios_areas WHERE usuario_id = $1', [usuarioId]); + } + + insertUserProfile(client: PoolClient, usuarioId: number, perfilId: number) { + return client.query( + 'INSERT INTO usuarios_perfis (usuario_id, perfil_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [usuarioId, perfilId], + ); + } + + upsertUserArea( + client: PoolClient, + usuarioId: number, + areaId: number, + funcao: string, + principal: boolean, + ativo: boolean, + ) { + return client.query( + ` + INSERT INTO usuarios_areas (usuario_id, area_id, funcao, principal, ativo) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (usuario_id, area_id) + DO UPDATE SET funcao = EXCLUDED.funcao, principal = EXCLUDED.principal, ativo = EXCLUDED.ativo, updated_at = NOW() + `, + [usuarioId, areaId, funcao, principal, ativo], + ); + } + + getSupervisorProfile() { + return this.database.query<{ id: number }>( + `SELECT id FROM perfis_acesso WHERE nome = 'Supervisor' LIMIT 1`, + ); + } + + insertAuditLog(input: AuditInput) { + return this.database.query( + ` + INSERT INTO admin_audit_logs (actor_user_id, actor_name, action, target_type, target_id, details) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [ + input.actorUserId || null, + input.actorName, + input.action, + input.targetType, + input.targetId, + input.details || null, + ], + ); + } +} diff --git a/src/modules/auth/decorators/current-user.decorator.ts b/src/modules/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..7ff831b --- /dev/null +++ b/src/modules/auth/decorators/current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthTokenPayload } from '../auth-token.service'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, context: ExecutionContext): AuthTokenPayload => { + const request = context.switchToHttp().getRequest(); + return request.user; + }, +);