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 { 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, 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,
|
title,
|
||||||
input.areaId || null,
|
areaId: input.areaId,
|
||||||
this.normalizeText(input.filename),
|
filename: this.normalizeText(input.filename),
|
||||||
this.normalizeText(input.mimetype),
|
mimetype: this.normalizeText(input.mimetype),
|
||||||
input.fileSize || null,
|
fileSize: input.fileSize,
|
||||||
input.contentBase64 || null,
|
contentBase64: input.contentBase64 || null,
|
||||||
this.normalizeText(input.notes),
|
notes: this.normalizeText(input.notes),
|
||||||
input.createdByUserId || null,
|
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();
|
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',
|
||||||
async createArea(input: AreaInput) {
|
String(usuarioId),
|
||||||
const nome = String(input.nome || '').trim();
|
'Perfis e especialidades alterados',
|
||||||
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],
|
|
||||||
);
|
);
|
||||||
|
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) {
|
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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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