REFACTOR: Reestruturado Admin access com auditoria JWT
Some checks are pending
Deploy Dev / deploy (push) Waiting to run
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:
parent
59db214ed9
commit
df51645e72
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 *
|
||||
`,
|
||||
[
|
||||
const actor = this.getAuditActor(user);
|
||||
const result = await this.adminAccessRepository.createAiContent({
|
||||
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,
|
||||
],
|
||||
);
|
||||
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));
|
||||
}
|
||||
|
||||
async createArea(input: AreaInput) {
|
||||
const nome = String(input.nome || '').trim();
|
||||
if (!nome) {
|
||||
throw new Error('Nome da area e obrigatorio');
|
||||
}
|
||||
|
||||
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],
|
||||
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: CreateAreaDto, user: AuthTokenPayload) {
|
||||
const nome = this.normalizeText(input.nome);
|
||||
|
||||
if (!nome) {
|
||||
throw new BadRequestException('Nome da área é obrigatório');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -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,
|
||||
|
||||
124
src/modules/admin/dto/admin-access.dto.ts
Normal file
124
src/modules/admin/dto/admin-access.dto.ts
Normal 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[];
|
||||
}
|
||||
465
src/modules/admin/repositories/admin-access.repository.ts
Normal file
465
src/modules/admin/repositories/admin-access.repository.ts
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
9
src/modules/auth/decorators/current-user.decorator.ts
Normal file
9
src/modules/auth/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user