F4.3 · dossier INF, GEDO IF y seguimiento
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhaseF4ReportDossierPermissions1790042400000 implements MigrationInterface {
|
||||
name = 'PhaseF4ReportDossierPermissions1790042400000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO permissions (code, description)
|
||||
VALUES
|
||||
('inspection_reports.edit', 'Editar el contenido de trabajo del Informe de inspección'),
|
||||
('inspection_reports.officialize', 'Registrar el IF y PDF oficial generado por GEDO'),
|
||||
('inspection_reports.follow_up', 'Agregar respuestas, notas y documentos al seguimiento del Informe')
|
||||
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
WITH mapping(role_code, permission_code) AS (
|
||||
VALUES
|
||||
('admin', 'inspection_reports.edit'),
|
||||
('admin', 'inspection_reports.officialize'),
|
||||
('admin', 'inspection_reports.follow_up'),
|
||||
('supervisor', 'inspection_reports.edit'),
|
||||
('supervisor', 'inspection_reports.officialize'),
|
||||
('supervisor', 'inspection_reports.follow_up'),
|
||||
('inspector', 'inspection_reports.edit'),
|
||||
('inspector', 'inspection_reports.officialize'),
|
||||
('inspector', 'inspection_reports.follow_up')
|
||||
)
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM mapping
|
||||
INNER JOIN roles role ON role.code = mapping.role_code
|
||||
INNER JOIN permissions permission ON permission.code = mapping.permission_code
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_inspection_reports_gedo_if_identifier
|
||||
ON inspection_reports(gedo_if_identifier)
|
||||
WHERE gedo_if_identifier IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS uq_inspection_reports_gedo_if_identifier`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions
|
||||
WHERE code IN (
|
||||
'inspection_reports.edit',
|
||||
'inspection_reports.officialize',
|
||||
'inspection_reports.follow_up'
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM permissions
|
||||
WHERE code IN (
|
||||
'inspection_reports.edit',
|
||||
'inspection_reports.officialize',
|
||||
'inspection_reports.follow_up'
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { InspectionReportFollowUpType } from '../../database/entities';
|
||||
|
||||
export class CreateInspectionReportFollowUpDto {
|
||||
@IsEnum(InspectionReportFollowUpType)
|
||||
eventType!: InspectionReportFollowUpType;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
referenceNumber?: string | null;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
occurredOn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
description?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class OfficializeInspectionReportGedoDto {
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(255)
|
||||
ifIdentifier!: string;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
officializedOn!: string;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
function optionalText(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
export class UpdateInspectionReportContentDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(10000)
|
||||
referenceText?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
generalObjective?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
specificObjective?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(40000)
|
||||
background?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(40000)
|
||||
legalFramework?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(40000)
|
||||
executiveSummary?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(80000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(40000)
|
||||
conclusion?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export const MAX_REPORT_DOSSIER_PDF_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export interface UploadedReportDossierFile {
|
||||
buffer: Buffer;
|
||||
originalname: string;
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface InspectedReportDossierPdf {
|
||||
originalName: string;
|
||||
mimeType: 'application/pdf';
|
||||
extension: '.pdf';
|
||||
}
|
||||
|
||||
export function inspectReportDossierPdf(
|
||||
file: UploadedReportDossierFile | undefined,
|
||||
): InspectedReportDossierPdf {
|
||||
if (!file?.buffer?.length || file.size <= 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_PDF_REQUIRED',
|
||||
message: 'Debés adjuntar un archivo PDF no vacío',
|
||||
});
|
||||
}
|
||||
if (file.size > MAX_REPORT_DOSSIER_PDF_BYTES || file.buffer.length > MAX_REPORT_DOSSIER_PDF_BYTES) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_PDF_TOO_LARGE',
|
||||
message: 'El PDF supera el máximo permitido de 25 MB',
|
||||
});
|
||||
}
|
||||
if (file.buffer.length < 5 || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_PDF_INVALID',
|
||||
message: 'El archivo adjunto no tiene una estructura PDF válida',
|
||||
});
|
||||
}
|
||||
const originalName = file.originalname
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!originalName) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_PDF_INVALID_NAME',
|
||||
message: 'El nombre original del PDF no es válido',
|
||||
});
|
||||
}
|
||||
return { originalName, mimeType: 'application/pdf', extension: '.pdf' };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||
import { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
|
||||
import { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
|
||||
import {
|
||||
MAX_REPORT_DOSSIER_PDF_BYTES,
|
||||
type UploadedReportDossierFile,
|
||||
} from './inspection-report-dossier-file';
|
||||
import { InspectionReportDossierService } from './inspection-report-dossier.service';
|
||||
|
||||
@Controller('inspection-reports/:reportId/dossier')
|
||||
export class InspectionReportDossierController {
|
||||
constructor(private readonly dossier: InspectionReportDossierService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
|
||||
return this.dossier.get(reportId);
|
||||
}
|
||||
|
||||
@Patch('content')
|
||||
@RequirePermissions('inspection_reports.edit')
|
||||
updateContent(
|
||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
||||
@Body() dto: UpdateInspectionReportContentDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.dossier.updateContent(reportId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('gedo')
|
||||
@RequirePermissions('inspection_reports.officialize')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 1 },
|
||||
}))
|
||||
officializeGedo(
|
||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
||||
@Body() dto: OfficializeInspectionReportGedoDto,
|
||||
@UploadedFile() file: UploadedReportDossierFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.dossier.officializeGedo(reportId, dto, file, principal, request);
|
||||
}
|
||||
|
||||
@Get('gedo/pdf')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
async gedoPdf(
|
||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const content = await this.dossier.gedoPdfContent(reportId);
|
||||
response.setHeader('Content-Type', 'application/pdf');
|
||||
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
await new Promise<void>((resolveSend, rejectSend) => {
|
||||
response.sendFile(content.filePath, (error) => {
|
||||
if (error) rejectSend(error);
|
||||
else resolveSend();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Get('follow-ups')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
followUps(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
|
||||
return this.dossier.listFollowUps(reportId);
|
||||
}
|
||||
|
||||
@Post('follow-ups')
|
||||
@RequirePermissions('inspection_reports.follow_up')
|
||||
@UseInterceptors(FilesInterceptor('files', 10, {
|
||||
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 10 },
|
||||
}))
|
||||
createFollowUp(
|
||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
||||
@Body() dto: CreateInspectionReportFollowUpDto,
|
||||
@UploadedFiles() files: UploadedReportDossierFile[] | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.dossier.createFollowUp(reportId, dto, files ?? [], principal, request);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('inspection-report-follow-up-files')
|
||||
export class InspectionReportFollowUpFileController {
|
||||
constructor(private readonly dossier: InspectionReportDossierService) {}
|
||||
|
||||
@Get(':fileId/content')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
async content(
|
||||
@Param('fileId', new ParseUUIDPipe({ version: '4' })) fileId: string,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const content = await this.dossier.followUpFileContent(fileId);
|
||||
response.setHeader('Content-Type', 'application/pdf');
|
||||
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
await new Promise<void>((resolveSend, rejectSend) => {
|
||||
response.sendFile(content.filePath, (error) => {
|
||||
if (error) rejectSend(error);
|
||||
else resolveSend();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { isAbsolute, parse, resolve } from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import {
|
||||
InspectionReport,
|
||||
InspectionReportFollowUp,
|
||||
InspectionReportFollowUpFile,
|
||||
InspectionReportFollowUpType,
|
||||
} from '../database/entities';
|
||||
import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service';
|
||||
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||
import type { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
|
||||
import type { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
|
||||
import {
|
||||
inspectReportDossierPdf,
|
||||
type UploadedReportDossierFile,
|
||||
} from './inspection-report-dossier-file';
|
||||
|
||||
interface ReportContext {
|
||||
id: string;
|
||||
code: string;
|
||||
actId: string;
|
||||
visitId: string;
|
||||
actCode: string;
|
||||
visitCode: string;
|
||||
generatedBy: string;
|
||||
gedoIfIdentifier: string | null;
|
||||
gedoOfficializedOn: string | null;
|
||||
gedoPdfStoredName: string | null;
|
||||
gedoPdfSizeBytes: number | null;
|
||||
gedoPdfSha256: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InspectionReportDossierService {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly deadlines: InspectionDeadlinesService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
const configured = config.get<string>('INSPECTION_REPORT_DOSSIER_ROOT')
|
||||
?? '/app/storage/asset-media/inspection-report-dossier';
|
||||
if (!isAbsolute(configured)) {
|
||||
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT must be an absolute path');
|
||||
}
|
||||
this.root = resolve(configured);
|
||||
if (this.root === parse(this.root).root) {
|
||||
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT cannot be the filesystem root');
|
||||
}
|
||||
}
|
||||
|
||||
async get(reportId: string) {
|
||||
await this.requireReport(this.dataSource.manager, reportId);
|
||||
const [report] = await this.dataSource.query(`
|
||||
SELECT
|
||||
report.id,
|
||||
report.code,
|
||||
report.reference_text AS "referenceText",
|
||||
report.general_objective AS "generalObjective",
|
||||
report.specific_objective AS "specificObjective",
|
||||
report.background,
|
||||
report.legal_framework AS "legalFramework",
|
||||
report.executive_summary AS "executiveSummary",
|
||||
report.description,
|
||||
report.conclusion,
|
||||
report.gedo_if_identifier AS "gedoIfIdentifier",
|
||||
report.gedo_officialized_on::text AS "gedoOfficializedOn",
|
||||
report.gedo_pdf_original_name AS "gedoPdfOriginalName",
|
||||
report.gedo_pdf_mime_type AS "gedoPdfMimeType",
|
||||
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
|
||||
report.gedo_pdf_sha256 AS "gedoPdfSha256",
|
||||
report.gedo_pdf_uploaded_at AS "gedoPdfUploadedAt",
|
||||
act.id AS "actId",
|
||||
act.code AS "actCode",
|
||||
act.urgency,
|
||||
act.deadline_days AS "deadlineDays",
|
||||
act.deadline_day_type AS "deadlineDayType",
|
||||
act.deadline_basis AS "deadlineBasis",
|
||||
act.deadline_base_on::text AS "deadlineBaseOn",
|
||||
act.deadline_due_on::text AS "deadlineDueOn",
|
||||
act.status AS "actStatus",
|
||||
visit.id AS "visitId",
|
||||
visit.code AS "visitCode"
|
||||
FROM inspection_reports report
|
||||
INNER JOIN inspection_acts act ON act.id = report.act_id
|
||||
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
|
||||
WHERE report.id = $1
|
||||
`, [reportId]) as Array<Record<string, unknown>>;
|
||||
const followUps = await this.listFollowUps(reportId);
|
||||
return { ...report, followUps: followUps.data };
|
||||
}
|
||||
|
||||
async updateContent(
|
||||
reportId: string,
|
||||
dto: UpdateInspectionReportContentDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const context = await this.requireReport(manager, reportId, true);
|
||||
await this.assertCanWorkReport(manager, context, principal);
|
||||
const repository = manager.getRepository(InspectionReport);
|
||||
const report = await repository.findOne({ where: { id: reportId } });
|
||||
if (!report) throw this.notFound();
|
||||
const before = this.contentSnapshot(report);
|
||||
const fields: Array<keyof UpdateInspectionReportContentDto> = [
|
||||
'referenceText',
|
||||
'generalObjective',
|
||||
'specificObjective',
|
||||
'background',
|
||||
'legalFramework',
|
||||
'executiveSummary',
|
||||
'description',
|
||||
'conclusion',
|
||||
];
|
||||
let changed = false;
|
||||
for (const field of fields) {
|
||||
if (dto[field] !== undefined) {
|
||||
(report as unknown as Record<string, unknown>)[field] = dto[field] ?? null;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_CONTENT_EMPTY_UPDATE',
|
||||
message: 'No se recibió ningún campo del Informe para actualizar',
|
||||
});
|
||||
}
|
||||
await repository.save(report);
|
||||
const after = this.contentSnapshot(report);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: 'INSPECTION_REPORT_CONTENT_UPDATED',
|
||||
entityType: 'inspection_report',
|
||||
entityId: reportId,
|
||||
beforeData: before,
|
||||
afterData: after,
|
||||
metadata: { actId: context.actId, visitId: context.visitId },
|
||||
}, manager);
|
||||
return after;
|
||||
});
|
||||
}
|
||||
|
||||
async officializeGedo(
|
||||
reportId: string,
|
||||
dto: OfficializeInspectionReportGedoDto,
|
||||
file: UploadedReportDossierFile | undefined,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const inspected = inspectReportDossierPdf(file);
|
||||
this.assertDate(dto.officializedOn, 'La fecha de carga en GEDO no es válida');
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const storedName = `gedo-${reportId}-${randomUUID()}.pdf`;
|
||||
const filePath = this.safePath(storedName);
|
||||
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||
await writeFile(filePath, file!.buffer, { mode: 0o600 });
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
const context = await this.requireReport(manager, reportId, true);
|
||||
await this.assertCanWorkReport(manager, context, principal);
|
||||
if (context.gedoIfIdentifier || context.gedoOfficializedOn || context.gedoPdfStoredName) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
|
||||
message: 'Este Informe ya tiene un IF oficial de GEDO y no puede sobrescribirse',
|
||||
});
|
||||
}
|
||||
await manager.query(`
|
||||
UPDATE inspection_reports
|
||||
SET gedo_if_identifier = $2,
|
||||
gedo_officialized_on = $3::date,
|
||||
gedo_pdf_original_name = $4,
|
||||
gedo_pdf_stored_name = $5,
|
||||
gedo_pdf_mime_type = 'application/pdf',
|
||||
gedo_pdf_size_bytes = $6,
|
||||
gedo_pdf_sha256 = $7,
|
||||
gedo_pdf_uploaded_at = CURRENT_TIMESTAMP,
|
||||
gedo_pdf_uploaded_by = $8,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [
|
||||
reportId,
|
||||
dto.ifIdentifier,
|
||||
dto.officializedOn,
|
||||
inspected.originalName,
|
||||
storedName,
|
||||
file!.buffer.length,
|
||||
sha256,
|
||||
principal.userId,
|
||||
]);
|
||||
const deadlineDueOn = await this.deadlines.applyGedoOfficialization(
|
||||
manager,
|
||||
context.actId,
|
||||
dto.officializedOn,
|
||||
);
|
||||
const after = {
|
||||
gedoIfIdentifier: dto.ifIdentifier,
|
||||
gedoOfficializedOn: dto.officializedOn,
|
||||
gedoPdfOriginalName: inspected.originalName,
|
||||
gedoPdfSizeBytes: file!.buffer.length,
|
||||
gedoPdfSha256: sha256,
|
||||
deadlineDueOn,
|
||||
};
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: 'INSPECTION_REPORT_GEDO_OFFICIALIZED',
|
||||
entityType: 'inspection_report',
|
||||
entityId: reportId,
|
||||
afterData: after,
|
||||
metadata: {
|
||||
actId: context.actId,
|
||||
visitId: context.visitId,
|
||||
officialLegalDocument: true,
|
||||
},
|
||||
}, manager);
|
||||
return after;
|
||||
});
|
||||
} catch (error) {
|
||||
await unlink(filePath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async gedoPdfContent(reportId: string) {
|
||||
const context = await this.requireReport(this.dataSource.manager, reportId);
|
||||
if (!context.gedoPdfStoredName || !context.gedoPdfSizeBytes || !context.gedoPdfSha256) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_FOUND',
|
||||
message: 'El Informe todavía no tiene un PDF oficial de GEDO',
|
||||
});
|
||||
}
|
||||
const filePath = this.safePath(context.gedoPdfStoredName);
|
||||
await this.verifyStoredFile(filePath, context.gedoPdfSizeBytes, context.gedoPdfSha256);
|
||||
return { filePath, originalName: `${context.gedoIfIdentifier ?? context.code}.pdf` };
|
||||
}
|
||||
|
||||
async listFollowUps(reportId: string) {
|
||||
await this.requireReport(this.dataSource.manager, reportId);
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
follow_up.id,
|
||||
follow_up.event_type AS "eventType",
|
||||
follow_up.reference_number AS "referenceNumber",
|
||||
follow_up.occurred_on::text AS "occurredOn",
|
||||
follow_up.description,
|
||||
follow_up.created_by AS "createdBy",
|
||||
follow_up.created_at AS "createdAt",
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id', file.id,
|
||||
'originalName', file.original_name,
|
||||
'mimeType', file.mime_type,
|
||||
'sizeBytes', file.size_bytes,
|
||||
'sha256', file.sha256,
|
||||
'createdAt', file.created_at
|
||||
) ORDER BY file.created_at, file.id)
|
||||
FROM inspection_report_follow_up_files file
|
||||
WHERE file.follow_up_id = follow_up.id
|
||||
), '[]'::jsonb) AS files
|
||||
FROM inspection_report_follow_ups follow_up
|
||||
WHERE follow_up.report_id = $1
|
||||
ORDER BY follow_up.occurred_on ASC, follow_up.created_at ASC, follow_up.id ASC
|
||||
`, [reportId]);
|
||||
return { data };
|
||||
}
|
||||
|
||||
async createFollowUp(
|
||||
reportId: string,
|
||||
dto: CreateInspectionReportFollowUpDto,
|
||||
files: UploadedReportDossierFile[],
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
this.assertDate(dto.occurredOn, 'La fecha del seguimiento no es válida');
|
||||
if (files.length > 10) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_FOLLOW_UP_TOO_MANY_FILES',
|
||||
message: 'Se permiten hasta 10 archivos PDF por registro de seguimiento',
|
||||
});
|
||||
}
|
||||
if (dto.eventType === InspectionReportFollowUpType.COMPANY_NOTE && files.length < 1) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_COMPANY_NOTE_FILE_REQUIRED',
|
||||
message: 'La respuesta formal de la empresa debe conservar al menos el PDF de la Nota recibida',
|
||||
});
|
||||
}
|
||||
const inspectedFiles = files.map((file) => ({ file, inspected: inspectReportDossierPdf(file) }));
|
||||
const followUpId = randomUUID();
|
||||
const stored: Array<{
|
||||
id: string;
|
||||
storedName: string;
|
||||
filePath: string;
|
||||
originalName: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}> = [];
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
for (const item of inspectedFiles) {
|
||||
const id = randomUUID();
|
||||
const storedName = `follow-up-${followUpId}-${id}.pdf`;
|
||||
const filePath = this.safePath(storedName);
|
||||
const sha256 = createHash('sha256').update(item.file.buffer).digest('hex');
|
||||
await writeFile(filePath, item.file.buffer, { mode: 0o600 });
|
||||
stored.push({
|
||||
id,
|
||||
storedName,
|
||||
filePath,
|
||||
originalName: item.inspected.originalName,
|
||||
sizeBytes: item.file.buffer.length,
|
||||
sha256,
|
||||
});
|
||||
}
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const context = await this.requireReport(manager, reportId, true);
|
||||
await this.assertCanWorkReport(manager, context, principal);
|
||||
await manager.getRepository(InspectionReportFollowUp).insert({
|
||||
id: followUpId,
|
||||
reportId,
|
||||
eventType: dto.eventType,
|
||||
referenceNumber: dto.referenceNumber ?? null,
|
||||
occurredOn: dto.occurredOn,
|
||||
description: dto.description ?? null,
|
||||
createdBy: principal.userId,
|
||||
});
|
||||
for (const file of stored) {
|
||||
await manager.getRepository(InspectionReportFollowUpFile).insert({
|
||||
id: file.id,
|
||||
followUpId,
|
||||
originalName: file.originalName,
|
||||
storedName: file.storedName,
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: file.sizeBytes,
|
||||
sha256: file.sha256,
|
||||
createdBy: principal.userId,
|
||||
});
|
||||
}
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: 'INSPECTION_REPORT_FOLLOW_UP_CREATED',
|
||||
entityType: 'inspection_report_follow_up',
|
||||
entityId: followUpId,
|
||||
afterData: {
|
||||
reportId,
|
||||
eventType: dto.eventType,
|
||||
referenceNumber: dto.referenceNumber ?? null,
|
||||
occurredOn: dto.occurredOn,
|
||||
description: dto.description ?? null,
|
||||
files: stored.map((file) => ({
|
||||
id: file.id,
|
||||
originalName: file.originalName,
|
||||
sizeBytes: file.sizeBytes,
|
||||
sha256: file.sha256,
|
||||
})),
|
||||
},
|
||||
metadata: {
|
||||
actId: context.actId,
|
||||
visitId: context.visitId,
|
||||
appendOnly: true,
|
||||
},
|
||||
}, manager);
|
||||
});
|
||||
return this.listFollowUps(reportId);
|
||||
} catch (error) {
|
||||
await Promise.all(stored.map((file) => unlink(file.filePath).catch(() => undefined)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async followUpFileContent(fileId: string) {
|
||||
const [file] = (await this.dataSource.query(`
|
||||
SELECT
|
||||
file.original_name AS "originalName",
|
||||
file.stored_name AS "storedName",
|
||||
file.size_bytes AS "sizeBytes",
|
||||
file.sha256
|
||||
FROM inspection_report_follow_up_files file
|
||||
WHERE file.id = $1
|
||||
`, [fileId])) as Array<{
|
||||
originalName: string;
|
||||
storedName: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
if (!file) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_REPORT_FOLLOW_UP_FILE_NOT_FOUND',
|
||||
message: 'Documento de seguimiento no encontrado',
|
||||
});
|
||||
}
|
||||
const filePath = this.safePath(file.storedName);
|
||||
await this.verifyStoredFile(filePath, Number(file.sizeBytes), file.sha256);
|
||||
return { filePath, originalName: file.originalName };
|
||||
}
|
||||
|
||||
private async requireReport(
|
||||
manager: EntityManager,
|
||||
reportId: string,
|
||||
lock = false,
|
||||
): Promise<ReportContext> {
|
||||
const suffix = lock ? 'FOR UPDATE OF report' : '';
|
||||
const [row] = (await manager.query(`
|
||||
SELECT
|
||||
report.id,
|
||||
report.code,
|
||||
report.act_id AS "actId",
|
||||
report.visit_id AS "visitId",
|
||||
report.generated_by AS "generatedBy",
|
||||
report.gedo_if_identifier AS "gedoIfIdentifier",
|
||||
report.gedo_officialized_on::text AS "gedoOfficializedOn",
|
||||
report.gedo_pdf_stored_name AS "gedoPdfStoredName",
|
||||
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
|
||||
report.gedo_pdf_sha256 AS "gedoPdfSha256",
|
||||
act.code AS "actCode",
|
||||
visit.code AS "visitCode"
|
||||
FROM inspection_reports report
|
||||
INNER JOIN inspection_acts act ON act.id = report.act_id
|
||||
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
|
||||
WHERE report.id = $1
|
||||
${suffix}
|
||||
`, [reportId])) as ReportContext[];
|
||||
if (!row) throw this.notFound();
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertCanWorkReport(
|
||||
manager: EntityManager,
|
||||
report: ReportContext,
|
||||
principal: AuthPrincipal,
|
||||
): Promise<void> {
|
||||
if (principal.roles.includes('admin') || principal.roles.includes('supervisor')) return;
|
||||
if (report.generatedBy === principal.userId) return;
|
||||
const [member] = (await manager.query(`
|
||||
SELECT 1 AS found
|
||||
FROM inspection_visit_members
|
||||
WHERE visit_id = $1
|
||||
AND user_id = $2
|
||||
AND included = true
|
||||
LIMIT 1
|
||||
`, [report.visitId, principal.userId])) as Array<{ found: number }>;
|
||||
if (member) return;
|
||||
throw new ForbiddenException({
|
||||
code: 'INSPECTION_REPORT_NOT_ASSIGNED',
|
||||
message: 'Sólo el inspector asignado a la inspección o su jefatura puede trabajar este Informe',
|
||||
});
|
||||
}
|
||||
|
||||
private contentSnapshot(report: InspectionReport): Record<string, unknown> {
|
||||
return {
|
||||
referenceText: report.referenceText,
|
||||
generalObjective: report.generalObjective,
|
||||
specificObjective: report.specificObjective,
|
||||
background: report.background,
|
||||
legalFramework: report.legalFramework,
|
||||
executiveSummary: report.executiveSummary,
|
||||
description: report.description,
|
||||
conclusion: report.conclusion,
|
||||
};
|
||||
}
|
||||
|
||||
private assertDate(value: string, message: string): void {
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
|
||||
throw new BadRequestException({ code: 'INSPECTION_REPORT_DATE_INVALID', message });
|
||||
}
|
||||
}
|
||||
|
||||
private safePath(storedName: string): string {
|
||||
const filePath = resolve(this.root, storedName);
|
||||
if (!filePath.startsWith(`${this.root}/`)) {
|
||||
throw new InternalServerErrorException({
|
||||
code: 'INSPECTION_REPORT_STORAGE_INVALID_PATH',
|
||||
message: 'Ruta de almacenamiento inválida',
|
||||
});
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private async verifyStoredFile(filePath: string, expectedSize: number, expectedSha256: string): Promise<void> {
|
||||
const metadata = await stat(filePath).catch(() => null);
|
||||
if (!metadata?.isFile() || metadata.size !== expectedSize) throw this.storageError();
|
||||
const buffer = await readFile(filePath);
|
||||
const actualSha256 = createHash('sha256').update(buffer).digest('hex');
|
||||
if (actualSha256 !== expectedSha256) throw this.storageError();
|
||||
}
|
||||
|
||||
private storageError(): InternalServerErrorException {
|
||||
return new InternalServerErrorException({
|
||||
code: 'INSPECTION_REPORT_STORAGE_INTEGRITY_ERROR',
|
||||
message: 'El archivo almacenado no supera la verificación de integridad',
|
||||
});
|
||||
}
|
||||
|
||||
private notFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'INSPECTION_REPORT_NOT_FOUND',
|
||||
message: 'Informe de inspección no encontrado',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,35 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
|
||||
import { DocumentDeliveryController } from './document-delivery.controller';
|
||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
|
||||
import { InspectionReportReviewService } from './inspection-report-review.service';
|
||||
import {
|
||||
InspectionReportDossierController,
|
||||
InspectionReportFollowUpFileController,
|
||||
} from './inspection-report-dossier.controller';
|
||||
import { InspectionReportDossierService } from './inspection-report-dossier.service';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||
import { InspectionReportsService } from './inspection-reports.service';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
imports: [AuditModule, InspectionDeadlinesModule],
|
||||
controllers: [
|
||||
InspectionReportsController,
|
||||
InspectionActReportController,
|
||||
InspectionReportDossierController,
|
||||
InspectionReportFollowUpFileController,
|
||||
DocumentDeliveryController,
|
||||
InspectionReportReviewController,
|
||||
InspectionReportRevisionContentController,
|
||||
],
|
||||
providers: [
|
||||
InspectionReportsService,
|
||||
InspectionReportWordService,
|
||||
InspectionReportDossierService,
|
||||
InspectionActPdfService,
|
||||
InspectionDocumentDeliveryService,
|
||||
SmtpDeliveryService,
|
||||
InspectionReportReviewService,
|
||||
],
|
||||
exports: [InspectionReportsService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
|
||||
import {
|
||||
InspectionReportDossierController,
|
||||
InspectionReportFollowUpFileController,
|
||||
} from '../../src/inspection-reports/inspection-report-dossier.controller';
|
||||
|
||||
function permissions(controller: object, method: string): string[] {
|
||||
const handler = controller[method as keyof typeof controller];
|
||||
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
|
||||
}
|
||||
|
||||
test('F4 report dossier separates read, edit, GEDO officialization and append-only follow-up', () => {
|
||||
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'get'), ['inspection_reports.read']);
|
||||
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'updateContent'), ['inspection_reports.edit']);
|
||||
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'officializeGedo'), ['inspection_reports.officialize']);
|
||||
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'gedoPdf'), ['inspection_reports.read']);
|
||||
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'followUps'), ['inspection_reports.read']);
|
||||
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'createFollowUp'), ['inspection_reports.follow_up']);
|
||||
assert.deepEqual(permissions(InspectionReportFollowUpFileController.prototype, 'content'), ['inspection_reports.read']);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
|
||||
test('F4 keeps company responses at INF level and never asks for a Finding selector', () => {
|
||||
const dto = read('src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts');
|
||||
const service = read('src/inspection-reports/inspection-report-dossier.service.ts');
|
||||
assert.doesNotMatch(dto, /findingId|findingIds/);
|
||||
assert.match(service, /InspectionReportFollowUpType\.COMPANY_NOTE/);
|
||||
assert.doesNotMatch(service, /inspection_findings.*follow_up_id/);
|
||||
});
|
||||
|
||||
test('F4 treats GEDO IF as the legal officialization of the existing INF and starts non-urgent deadline there', () => {
|
||||
const service = read('src/inspection-reports/inspection-report-dossier.service.ts');
|
||||
assert.match(service, /gedo_if_identifier/);
|
||||
assert.match(service, /gedo_officialized_on/);
|
||||
assert.match(service, /applyGedoOfficialization/);
|
||||
assert.match(service, /INSPECTION_REPORT_ALREADY_OFFICIALIZED/);
|
||||
});
|
||||
|
||||
test('F4 removes the active Director review controller from the report module', () => {
|
||||
const moduleSource = read('src/inspection-reports/inspection-reports.module.ts');
|
||||
assert.doesNotMatch(moduleSource, /InspectionReportReviewController/);
|
||||
assert.doesNotMatch(moduleSource, /InspectionReportReviewService/);
|
||||
assert.match(moduleSource, /InspectionReportDossierController/);
|
||||
});
|
||||
Reference in New Issue
Block a user