REFACTOR: Reestruturado Admin access com auditoria JWT
Some checks are pending
Deploy Dev / deploy (push) Waiting to run

- 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
This commit is contained in:
Rafael Alves Lopes 2026-05-29 16:21:11 -03:00
parent 59db214ed9
commit df51645e72
6 changed files with 766 additions and 473 deletions

View File

@ -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 { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AdminAccessService } from './admin-access.service'; import { AdminAccessService } from './admin-access.service';
import { Roles } from '../auth/decorators/roles.decorator'; 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') @ApiTags('Administracao')
@Controller('admin/access') @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.', description: 'Retorna dados auxiliares usados nos formularios administrativos, como perfis, areas e opcoes de acesso.',
}) })
@ApiResponse({ status: 200, description: 'Opcoes administrativas retornadas.' }) @ApiResponse({ status: 200, description: 'Opcoes administrativas retornadas.' })
@Roles('Admin', 'Supervisor') @Roles('Admin', 'Supervisor', 'Agente')
@Get('options') @Get('options')
getOptions() { getOptions() {
return this.adminAccessService.getOptions(); return this.adminAccessService.getOptions();
@ -69,8 +77,8 @@ export class AdminAccessController {
@ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' })
@Roles('Admin') @Roles('Admin')
@Get('ai-contents/:id/file') @Get('ai-contents/:id/file')
getAiContentFile(@Param('id') id: string) { getAiContentFile(@Param('id', ParseIntPipe) id: number) {
return this.adminAccessService.getAiContentFile(Number(id)); return this.adminAccessService.getAiContentFile(id);
} }
@ApiOperation({ @ApiOperation({
@ -88,23 +96,13 @@ export class AdminAccessController {
fileSize: { type: 'number', nullable: true, example: 102400 }, fileSize: { type: 'number', nullable: true, example: 102400 },
contentBase64: { type: 'string', nullable: true, description: 'Arquivo em base64.' }, contentBase64: { type: 'string', nullable: true, description: 'Arquivo em base64.' },
notes: { type: 'string', nullable: true, example: 'Regras internas de ferias.' }, notes: { type: 'string', nullable: true, example: 'Regras internas de ferias.' },
createdByUserId: { type: 'number', nullable: true, example: 10 },
}, },
}, },
}) })
@Roles('Admin') @Roles('Admin')
@Post('ai-contents') @Post('ai-contents')
createAiContent(@Body() body: { createAiContent(@Body() body: CreateAiContentDto, @CurrentUser() user: AuthTokenPayload) {
title?: string; return this.adminAccessService.createAiContent(body, user);
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);
} }
@ApiOperation({ @ApiOperation({
@ -114,8 +112,8 @@ export class AdminAccessController {
@ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' })
@Roles('Admin') @Roles('Admin')
@Delete('ai-contents/:id') @Delete('ai-contents/:id')
deleteAiContent(@Param('id') id: string) { deleteAiContent(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthTokenPayload) {
return this.adminAccessService.deleteAiContent(Number(id)); return this.adminAccessService.deleteAiContent(id, user);
} }
@ApiOperation({ @ApiOperation({
@ -145,8 +143,8 @@ export class AdminAccessController {
}) })
@Roles('Admin') @Roles('Admin')
@Post('areas') @Post('areas')
createArea(@Body() body: { nome: string; descricao?: string | null; responsavelUsuarioId?: number | null }) { createArea(@Body() body: CreateAreaDto, @CurrentUser() user: AuthTokenPayload) {
return this.adminAccessService.createArea(body); return this.adminAccessService.createArea(body, user);
} }
@ApiOperation({ @ApiOperation({
@ -157,10 +155,11 @@ export class AdminAccessController {
@Roles('Admin') @Roles('Admin')
@Put('areas/:id') @Put('areas/:id')
updateArea( updateArea(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() body: { nome?: string; descricao?: string | null; responsavelUsuarioId?: number | null; ativo?: boolean }, @Body() body: UpdateAreaDto,
@CurrentUser() user: AuthTokenPayload,
) { ) {
return this.adminAccessService.updateArea(Number(id), body); return this.adminAccessService.updateArea(id, body, user);
} }
@ApiOperation({ @ApiOperation({
@ -170,8 +169,8 @@ export class AdminAccessController {
@ApiParam({ name: 'id', description: 'ID da area.' }) @ApiParam({ name: 'id', description: 'ID da area.' })
@Roles('Admin') @Roles('Admin')
@Delete('areas/:id') @Delete('areas/:id')
deleteArea(@Param('id') id: string) { deleteArea(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthTokenPayload) {
return this.adminAccessService.deleteArea(Number(id)); return this.adminAccessService.deleteArea(id, user);
} }
@ApiOperation({ @ApiOperation({
@ -215,19 +214,10 @@ export class AdminAccessController {
@Roles('Admin') @Roles('Admin')
@Put('users/:id') @Put('users/:id')
updateUserAccess( updateUserAccess(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() body: { @Body() body: UpdateUserAccessDto,
perfilId?: number | null; @CurrentUser() user: AuthTokenPayload,
perfilIds?: number[];
areaId?: number | null;
especialidades?: Array<{
areaId: number;
funcao?: string | null;
principal?: boolean;
ativo?: boolean;
}>;
},
) { ) {
return this.adminAccessService.updateUserAccess(Number(id), body); return this.adminAccessService.updateUserAccess(id, body, user);
} }
} }

View File

@ -1,84 +1,26 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../../infra/database/database.service'; 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 { interface AuditActor {
perfilId?: number | null; userId?: number | null;
perfilIds?: number[]; name: string;
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;
} }
@Injectable() @Injectable()
export class AdminAccessService { export class AdminAccessService {
constructor(private readonly database: DatabaseService) {} constructor(private readonly adminAccessRepository: AdminAccessRepository) {}
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
);
`);
}
async getOptions() { async getOptions() {
const [profiles, areas] = await Promise.all([ const [profiles, areas] = await Promise.all([
this.database.query<{ id: number; nome: string }>( this.adminAccessRepository.getProfiles(),
'SELECT id, nome FROM perfis_acesso ORDER BY nome', this.adminAccessRepository.getActiveAreasOptions(),
),
this.database.query<{ id: number; nome: string }>(
'SELECT id, nome FROM areas WHERE ativo = TRUE ORDER BY nome',
),
]); ]);
return { return {
@ -97,59 +39,13 @@ export class AdminAccessService {
avgHandling, avgHandling,
avgFirstResponse, avgFirstResponse,
] = await Promise.all([ ] = await Promise.all([
this.database.query<{ total: string }>( this.adminAccessRepository.getCurrentMonthAttendances(),
` this.adminAccessRepository.getPreviousMonthAttendances(),
SELECT COUNT(*)::TEXT AS total this.adminAccessRepository.getActiveUsersInCurrentMonth(),
FROM whatsapp_chat_atribuicoes this.adminAccessRepository.getTotalActiveUsers(),
WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) this.adminAccessRepository.getChannelDistribution(),
`, this.adminAccessRepository.getAverageHandlingMinutes(),
), this.adminAccessRepository.getAverageFirstResponseMinutes(),
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)
`,
),
]); ]);
const currentTotal = Number(currentMonth.rows[0]?.total || 0); const currentTotal = Number(currentMonth.rows[0]?.total || 0);
@ -175,31 +71,7 @@ export class AdminAccessService {
} }
async getAttendantRanking(areaId?: number | null) { async getAttendantRanking(areaId?: number | null) {
const params: unknown[] = []; const result = await this.adminAccessRepository.getAttendantRanking(areaId);
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,
);
return result.rows.map((row: any) => ({ return result.rows.map((row: any) => ({
id: `${row.id}-${row.area}`, id: `${row.id}-${row.area}`,
@ -215,78 +87,7 @@ export class AdminAccessService {
const safePage = Math.max(1, Number(page) || 1); const safePage = Math.max(1, Number(page) || 1);
const safeLimit = Math.min(100, Math.max(1, Number(limit) || 100)); const safeLimit = Math.min(100, Math.max(1, Number(limit) || 100));
const offset = (safePage - 1) * safeLimit; const offset = (safePage - 1) * safeLimit;
const result = await this.adminAccessRepository.listAuditLogs(safeLimit, offset);
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],
);
return { return {
page: safePage, page: safePage,
@ -297,140 +98,67 @@ export class AdminAccessService {
} }
async listAiContents() { async listAiContents() {
const result = await this.database.query( const result = await this.adminAccessRepository.listAiContents();
`
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
`,
);
return result.rows; return result.rows;
} }
async getAiContentFile(id: number) { async getAiContentFile(id: number) {
const result = await this.database.query( const result = await this.adminAccessRepository.getAiContentFile(id);
`
SELECT id, title, filename, mimetype, content_base64
FROM ai_knowledge_contents
WHERE id = $1
`,
[id],
);
if (!result.rows[0]) { 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]; return result.rows[0];
} }
async createAiContent(input: AiContentInput) { async createAiContent(input: CreateAiContentDto, user: AuthTokenPayload) {
const title = String(input.title || '').trim(); const title = this.normalizeText(input.title);
if (!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( const actor = this.getAuditActor(user);
` const result = await this.adminAccessRepository.createAiContent({
INSERT INTO ai_knowledge_contents ( title,
title, area_id, filename, mimetype, file_size, content_base64, areaId: input.areaId,
status, notes, created_by_user_id, updated_at filename: this.normalizeText(input.filename),
) mimetype: this.normalizeText(input.mimetype),
VALUES ($1, $2, $3, $4, $5, $6, 'available', $7, $8, CURRENT_TIMESTAMP) fileSize: input.fileSize,
RETURNING * contentBase64: input.contentBase64 || null,
`, notes: this.normalizeText(input.notes),
[ createdByUserId: actor.userId,
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,
],
);
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(); return this.listAiContents();
} }
async deleteAiContent(id: number) { async deleteAiContent(id: number, user: AuthTokenPayload) {
await this.database.query('DELETE FROM ai_knowledge_contents WHERE id = $1', [id]); const result = await this.adminAccessRepository.deleteAiContent(id);
await this.logAudit('Conteúdo de IA removido', 'Conteúdo IA', String(id), 'Remoção de conteúdo');
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(); return this.listAiContents();
} }
async listAreas() { async listAreas() {
const result = await this.database.query( const result = await this.adminAccessRepository.listAreas();
`
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
`,
);
return result.rows; return result.rows;
} }
async listUsers() { async listUsers() {
const result = await this.database.query( const result = await this.adminAccessRepository.listUsers();
`
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
`,
);
return result.rows.map((user: any) => { return result.rows.map((user: any) => {
const perfis = Array.isArray(user.perfis) ? user.perfis : []; const perfis = Array.isArray(user.perfis) ? user.perfis : [];
@ -452,10 +180,10 @@ export class AdminAccessService {
}); });
} }
async updateUserAccess(usuarioId: number, input: AccessUpdateInput) { async updateUserAccess(usuarioId: number, input: UpdateUserAccessDto, user: AuthTokenPayload) {
await this.database.transaction(async (client) => { await this.adminAccessRepository.transaction(async (client) => {
await client.query('DELETE FROM usuarios_perfis WHERE usuario_id = $1', [usuarioId]); await this.adminAccessRepository.deleteUserProfiles(client, usuarioId);
await client.query('DELETE FROM usuarios_areas WHERE usuario_id = $1', [usuarioId]); await this.adminAccessRepository.deleteUserAreas(client, usuarioId);
const perfilIds = Array.isArray(input.perfilIds) const perfilIds = Array.isArray(input.perfilIds)
? input.perfilIds ? input.perfilIds
@ -464,10 +192,7 @@ export class AdminAccessService {
: []; : [];
for (const perfilId of [...new Set(perfilIds.filter(Boolean))]) { for (const perfilId of [...new Set(perfilIds.filter(Boolean))]) {
await client.query( await this.adminAccessRepository.insertUserProfile(client, usuarioId, perfilId);
'INSERT INTO usuarios_perfis (usuario_id, perfil_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[usuarioId, perfilId],
);
} }
const especialidades = Array.isArray(input.especialidades) const especialidades = Array.isArray(input.especialidades)
@ -484,141 +209,119 @@ export class AdminAccessService {
const isPrincipal = Boolean(item.principal) && !hasPrincipal; const isPrincipal = Boolean(item.principal) && !hasPrincipal;
hasPrincipal = hasPrincipal || isPrincipal; hasPrincipal = hasPrincipal || isPrincipal;
await client.query( await this.adminAccessRepository.upsertUserArea(
` client,
INSERT INTO usuarios_areas (usuario_id, area_id, funcao, principal, ativo) usuarioId,
VALUES ($1, $2, $3, $4, $5) areaId,
ON CONFLICT (usuario_id, area_id) this.normalizeRole(item.funcao),
DO UPDATE SET funcao = EXCLUDED.funcao, principal = EXCLUDED.principal, ativo = EXCLUDED.ativo, updated_at = NOW() isPrincipal,
`, item.ativo !== false,
[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'); await this.logAudit(
return this.listUsers().then((users) => users.find((user) => user.id === usuarioId)); 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) { async createArea(input: CreateAreaDto, user: AuthTokenPayload) {
const nome = String(input.nome || '').trim(); const nome = this.normalizeText(input.nome);
if (!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( const result = await this.adminAccessRepository.createArea({
` nome,
INSERT INTO areas (nome, descricao, responsavel_usuario_id, ativo, created_at, updated_at) descricao: this.normalizeText(input.descricao),
VALUES ($1, $2, $3, TRUE, NOW(), NOW()) responsavelUsuarioId: input.responsavelUsuarioId,
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],
);
if (input.responsavelUsuarioId) { if (input.responsavelUsuarioId) {
await this.ensureAreaSupervisor(input.responsavelUsuarioId, result.rows[0].id); 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(); return this.listAreas();
} }
async updateArea(areaId: number, input: AreaInput) { async updateArea(areaId: number, input: UpdateAreaDto, user: AuthTokenPayload) {
const result = await this.database.query( const result = await this.adminAccessRepository.updateArea(areaId, {
` nome: this.normalizeText(input.nome),
UPDATE areas descricao: this.normalizeText(input.descricao),
SET responsavelUsuarioId: input.responsavelUsuarioId,
nome = COALESCE($2, nome), ativo: input.ativo,
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,
],
);
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.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(); return this.listAreas();
} }
async deleteArea(areaId: number) { async deleteArea(areaId: number, user: AuthTokenPayload) {
await this.database.query( await this.adminAccessRepository.deactivateArea(areaId);
` await this.logAudit(
UPDATE areas this.getAuditActor(user),
SET ativo = FALSE, 'Especialidade desativada',
updated_at = NOW() 'Especialidade',
WHERE id = $1 String(areaId),
`, 'Especialidade removida da operação',
[areaId],
); );
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(); return this.listAreas();
} }
private async logAudit(action: string, targetType: string, targetId: string, details?: string | null) { private async logAudit(actor: AuditActor, action: string, targetType: string, targetId: string, details?: string | null) {
await this.database.query( await this.adminAccessRepository
` .insertAuditLog({
INSERT INTO admin_audit_logs (actor_name, action, target_type, target_id, details) actorUserId: actor.userId,
VALUES ('Admin', $1, $2, $3, $4) actorName: actor.name,
`, action,
[action, targetType, targetId, details || null], targetType,
).catch(() => undefined); targetId,
details,
})
.catch(() => undefined);
} }
private async ensureAreaSupervisor(usuarioId: number, areaId: number) { private async ensureAreaSupervisor(usuarioId: number, areaId: number) {
const supervisorProfile = await this.database.query<{ id: number }>( const supervisorProfile = await this.adminAccessRepository.getSupervisorProfile();
`SELECT id FROM perfis_acesso WHERE nome = 'Supervisor' LIMIT 1`,
);
const perfilId = supervisorProfile.rows[0]?.id; const perfilId = supervisorProfile.rows[0]?.id;
await this.database.transaction(async (client) => { await this.adminAccessRepository.transaction(async (client) => {
if (perfilId) { if (perfilId) {
await client.query( await this.adminAccessRepository.insertUserProfile(client, usuarioId, perfilId);
`INSERT INTO usuarios_perfis (usuario_id, perfil_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[usuarioId, perfilId],
);
} }
await client.query( await this.adminAccessRepository.upsertUserArea(client, usuarioId, areaId, 'Supervisor', true, true);
`
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],
);
}); });
} }
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) { private normalizeText(value?: string | null) {
const text = String(value || '').trim(); const text = String(value || '').trim();
return text || null; return text || null;

View File

@ -9,6 +9,7 @@ import { CustomerContactsController } from './customer-contacts.controller';
import { CustomerContactsService } from './customer-contacts.service'; import { CustomerContactsService } from './customer-contacts.service';
import { KnowledgeBaseController } from './knowledge-base.controller'; import { KnowledgeBaseController } from './knowledge-base.controller';
import { KnowledgeBaseService } from './knowledge-base.service'; import { KnowledgeBaseService } from './knowledge-base.service';
import { AdminAccessRepository } from './repositories/admin-access.repository';
@Module({ @Module({
controllers: [ controllers: [
@ -20,6 +21,7 @@ import { KnowledgeBaseService } from './knowledge-base.service';
], ],
providers: [ providers: [
AdminAccessService, AdminAccessService,
AdminAccessRepository,
AgentNotesService, AgentNotesService,
AgentPresenceService, AgentPresenceService,
CustomerContactsService, CustomerContactsService,

View File

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

View File

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

View File

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