From fbe8f2e8cf14dcdd8b1dcd14074ee4b3a5a75bc1 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 19:55:04 -0300 Subject: [PATCH] =?UTF-8?q?F4.3=20=C2=B7=20dossier=20INF,=20GEDO=20IF=20y?= =?UTF-8?q?=20seguimiento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...000-phase-f4-report-dossier-permissions.ts | 64 +++ .../create-inspection-report-follow-up.dto.ts | 23 + .../officialize-inspection-report-gedo.dto.ts | 13 + .../update-inspection-report-content.dto.ts | 58 ++ .../inspection-report-dossier-file.ts | 50 ++ .../inspection-report-dossier.controller.ts | 128 +++++ .../inspection-report-dossier.service.ts | 516 ++++++++++++++++++ .../inspection-reports.module.ts | 16 +- ...se-f4-report-dossier-authorization.test.ts | 23 + .../test/unit/phase-f4-report-dossier.test.ts | 30 + 10 files changed, 915 insertions(+), 6 deletions(-) create mode 100644 api-v3/src/database/migrations/1790042400000-phase-f4-report-dossier-permissions.ts create mode 100644 api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts create mode 100644 api-v3/src/inspection-reports/dto/officialize-inspection-report-gedo.dto.ts create mode 100644 api-v3/src/inspection-reports/dto/update-inspection-report-content.dto.ts create mode 100644 api-v3/src/inspection-reports/inspection-report-dossier-file.ts create mode 100644 api-v3/src/inspection-reports/inspection-report-dossier.controller.ts create mode 100644 api-v3/src/inspection-reports/inspection-report-dossier.service.ts create mode 100644 api-v3/test/unit/phase-f4-report-dossier-authorization.test.ts create mode 100644 api-v3/test/unit/phase-f4-report-dossier.test.ts diff --git a/api-v3/src/database/migrations/1790042400000-phase-f4-report-dossier-permissions.ts b/api-v3/src/database/migrations/1790042400000-phase-f4-report-dossier-permissions.ts new file mode 100644 index 0000000..8cf84b3 --- /dev/null +++ b/api-v3/src/database/migrations/1790042400000-phase-f4-report-dossier-permissions.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseF4ReportDossierPermissions1790042400000 implements MigrationInterface { + name = 'PhaseF4ReportDossierPermissions1790042400000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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' + ) + `); + } +} diff --git a/api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts b/api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts new file mode 100644 index 0000000..c47ad2c --- /dev/null +++ b/api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts @@ -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; +} diff --git a/api-v3/src/inspection-reports/dto/officialize-inspection-report-gedo.dto.ts b/api-v3/src/inspection-reports/dto/officialize-inspection-report-gedo.dto.ts new file mode 100644 index 0000000..5970bfa --- /dev/null +++ b/api-v3/src/inspection-reports/dto/officialize-inspection-report-gedo.dto.ts @@ -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; +} diff --git a/api-v3/src/inspection-reports/dto/update-inspection-report-content.dto.ts b/api-v3/src/inspection-reports/dto/update-inspection-report-content.dto.ts new file mode 100644 index 0000000..e2a0fc5 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/update-inspection-report-content.dto.ts @@ -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; +} diff --git a/api-v3/src/inspection-reports/inspection-report-dossier-file.ts b/api-v3/src/inspection-reports/inspection-report-dossier-file.ts new file mode 100644 index 0000000..9a0a102 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-dossier-file.ts @@ -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' }; +} diff --git a/api-v3/src/inspection-reports/inspection-report-dossier.controller.ts b/api-v3/src/inspection-reports/inspection-report-dossier.controller.ts new file mode 100644 index 0000000..93f8804 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-dossier.controller.ts @@ -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 { + 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((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 { + 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((resolveSend, rejectSend) => { + response.sendFile(content.filePath, (error) => { + if (error) rejectSend(error); + else resolveSend(); + }); + }); + } +} diff --git a/api-v3/src/inspection-reports/inspection-report-dossier.service.ts b/api-v3/src/inspection-reports/inspection-report-dossier.service.ts new file mode 100644 index 0000000..4c1af38 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-dossier.service.ts @@ -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('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>; + 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 = [ + '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)[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 { + 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 { + 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 { + 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 { + 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', + }); + } +} diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index a997459..c9967b2 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -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], }) diff --git a/api-v3/test/unit/phase-f4-report-dossier-authorization.test.ts b/api-v3/test/unit/phase-f4-report-dossier-authorization.test.ts new file mode 100644 index 0000000..2ec9893 --- /dev/null +++ b/api-v3/test/unit/phase-f4-report-dossier-authorization.test.ts @@ -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']); +}); diff --git a/api-v3/test/unit/phase-f4-report-dossier.test.ts b/api-v3/test/unit/phase-f4-report-dossier.test.ts new file mode 100644 index 0000000..d72d92c --- /dev/null +++ b/api-v3/test/unit/phase-f4-report-dossier.test.ts @@ -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/); +});