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, 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 { AuditAction, InspectionReportStatus, } from '../database/entities'; import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto'; import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024; export interface UploadedInspectionReportFile { buffer: Buffer; originalname: string; mimetype: string; size: number; } interface ReportRow { id: string; actId: string; visitId: string; code: string; status: InspectionReportStatus; executiveSummary: string | null; reportDescription: string | null; gedoIfIdentifier: string | null; gedoOfficializedAt: Date | null; } function reportNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado', }); } @Injectable() export class InspectionReportWorkflowService { private readonly root: string; constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, config: ConfigService, ) { const configured = config.get('INSPECTION_REPORT_UPLOAD_ROOT') ?? '/app/storage/asset-media/inspection-reports'; if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute'); this.root = resolve(configured); if (this.root === parse(this.root).root) { throw new Error('INSPECTION_REPORT_UPLOAD_ROOT cannot be filesystem root'); } } async updateNarrative( reportId: string, dto: UpdateInspectionReportDto, principal: AuthPrincipal, request: RequestWithContext, ) { if (Object.keys(dto).length === 0) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); } return this.dataSource.transaction(async (manager) => { const report = await this.lockReport(manager, reportId); if (report.status !== InspectionReportStatus.WORKING) { throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED', message: 'El contenido editable del INF queda cerrado cuando se registra su IF oficial de GEDO', }); } const before = { executiveSummary: report.executiveSummary, reportDescription: report.reportDescription, }; const executiveSummary = dto.executiveSummary !== undefined ? dto.executiveSummary : report.executiveSummary; const reportDescription = dto.description !== undefined ? dto.description : report.reportDescription; await manager.query(` UPDATE inspection_reports SET executive_summary=$2, report_description=$3, word_status=CASE WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 THEN 'PENDING' ELSE word_status END, word_original_name=CASE WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 THEN NULL ELSE word_original_name END, word_stored_name=CASE WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 THEN NULL ELSE word_stored_name END, word_mime_type=CASE WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 THEN NULL ELSE word_mime_type END, word_size_bytes=CASE WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 THEN NULL ELSE word_size_bytes END, word_sha256=CASE WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 THEN NULL ELSE word_sha256 END, word_generated_at=CASE WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 THEN NULL ELSE word_generated_at END, word_error=CASE WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 THEN NULL ELSE word_error END, updated_at=CURRENT_TIMESTAMP WHERE id=$1 `, [reportId, executiveSummary, reportDescription]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_REPORT_UPDATED, entityType: 'inspection_report', entityId: reportId, beforeData: before, afterData: { executiveSummary, reportDescription }, metadata: { reportCode: report.code, actId: report.actId }, }, manager); return this.getWorkflowView(manager, reportId); }); } async officialize( reportId: string, dto: OfficializeInspectionReportDto, file: UploadedInspectionReportFile | undefined, principal: AuthPrincipal, request: RequestWithContext, ) { this.assertPdf(file); const id = randomUUID(); const storedName = `gedo-${id}.pdf`; const filePath = resolve(this.root, storedName); const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); await mkdir(this.root, { recursive: true, mode: 0o700 }); await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); try { return await this.dataSource.transaction(async (manager) => { const report = await this.lockReport(manager, reportId); if (report.status !== InspectionReportStatus.WORKING) { throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED', message: 'El IF oficial de GEDO ya fue registrado y es inmutable', }); } const officializedAt = new Date(dto.gedoOfficializedAt); const now = new Date(); if (officializedAt.getTime() > now.getTime() + 24 * 60 * 60 * 1000) { throw new BadRequestException({ code: 'INVALID_GEDO_DATE', message: 'La fecha de GEDO no puede estar más de 24 horas en el futuro', }); } await manager.query(` UPDATE inspection_reports SET status='OFFICIALIZED', gedo_if_identifier=$2, gedo_officialized_at=$3, 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, updated_at=CURRENT_TIMESTAMP WHERE id=$1 `, [ reportId, dto.gedoIfIdentifier, officializedAt, file!.originalname, storedName, file!.buffer.length, sha256, ]); // GEDO oficializa el INF, pero no equivale por sí solo a la notificación // administrativa de un Acta no urgente. El vencimiento queda pendiente // hasta que el procedimiento defina y registre el evento de notificación. await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_REPORT_OFFICIALIZED, entityType: 'inspection_report', entityId: reportId, afterData: { status: InspectionReportStatus.OFFICIALIZED, gedoIfIdentifier: dto.gedoIfIdentifier, gedoOfficializedAt: officializedAt, gedoPdfOriginalName: file!.originalname, gedoPdfSha256: sha256, }, metadata: { reportCode: report.code, actId: report.actId, immutable: true, deadlineActivation: 'PENDING_NOTIFICATION_EVENT', }, }, manager); return this.getWorkflowView(manager, reportId); }); } catch (error) { await unlink(filePath).catch(() => undefined); throw error; } } async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { const [row] = await this.dataSource.query(` SELECT gedo_pdf_original_name AS "originalName", gedo_pdf_stored_name AS "storedName", gedo_pdf_mime_type AS "mimeType", gedo_pdf_size_bytes::integer AS "sizeBytes", gedo_pdf_sha256 AS sha256 FROM inspection_reports WHERE id=$1 `, [reportId]) as Array<{ originalName: string | null; storedName: string | null; mimeType: string | null; sizeBytes: number | null; sha256: string | null; }>; if (!row) throw reportNotFound(); if (!row.originalName || !row.storedName || !row.sizeBytes || !row.sha256) { throw new NotFoundException({ code: 'INSPECTION_REPORT_GEDO_PDF_NOT_AVAILABLE', message: 'El PDF oficial de GEDO todavía no está disponible', }); } const filePath = resolve(this.root, row.storedName); if (filePath === this.root || !filePath.startsWith(`${this.root}/`)) throw this.reportStorageError(); const fileStat = await stat(filePath).catch(() => null); if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.reportStorageError(); const buffer = await readFile(filePath); const sha256 = createHash('sha256').update(buffer).digest('hex'); if (sha256 !== row.sha256) throw this.reportStorageError(); return { filePath, originalName: row.originalName, mimeType: row.mimeType ?? 'application/pdf', }; } async followUpContent(reportId: string, followUpId: string): Promise<{ filePath: string; originalName: string; mimeType: string; sizeBytes: number }> { const [item] = await this.dataSource.query(` SELECT original_name AS "originalName",stored_name AS "storedName", mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 FROM inspection_report_follow_ups WHERE report_id=$1 AND id=$2 AND stored_name IS NOT NULL `, [reportId, followUpId]) as Array<{ originalName: string; storedName: string; mimeType: string; sizeBytes: number; sha256: string }>; if (!item) throw new NotFoundException({ code: 'REPORT_FOLLOW_UP_FILE_NOT_FOUND', message: 'Adjunto de seguimiento inexistente' }); if (!/^followup-[a-f0-9-]+\.[a-z0-9]+$/.test(item.storedName)) throw this.reportStorageError(); const filePath = resolve(this.root, item.storedName); if (!filePath.startsWith(`${this.root}/`)) throw this.reportStorageError(); const file = await stat(filePath).catch(() => null); if (!file?.isFile() || file.size !== item.sizeBytes) throw this.reportStorageError(); const buffer = await readFile(filePath); if (createHash('sha256').update(buffer).digest('hex') !== item.sha256) throw this.reportStorageError(); return { filePath, originalName: item.originalName, mimeType: item.mimeType, sizeBytes: item.sizeBytes }; } async addFollowUp( reportId: string, dto: CreateInspectionReportFollowUpDto, file: UploadedInspectionReportFile | undefined, principal: AuthPrincipal, request: RequestWithContext, ) { if (file && file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) { throw new BadRequestException({ code: 'INSPECTION_REPORT_FILE_TOO_LARGE', message: 'El archivo supera el máximo permitido de 40 MB', }); } const followUpId = randomUUID(); const ext = this.safeExtension(file?.originalname); const storedName = file ? `followup-${followUpId}${ext}` : null; const filePath = storedName ? resolve(this.root, storedName) : null; const sha256 = file ? createHash('sha256').update(file.buffer).digest('hex') : null; if (filePath && file) { await mkdir(this.root, { recursive: true, mode: 0o700 }); await writeFile(filePath, file.buffer, { flag: 'wx', mode: 0o600 }); } try { return await this.dataSource.transaction(async (manager) => { const report = await this.lockReport(manager, reportId); const occurredAt = new Date(dto.occurredAt); await manager.query(` INSERT INTO inspection_report_follow_ups ( id,report_id,type,external_reference,occurred_at,description, original_name,stored_name,mime_type,size_bytes,sha256,created_by ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) `, [ followUpId, reportId, dto.type, dto.externalReference ?? null, occurredAt, dto.description ?? null, file?.originalname ?? null, storedName, file?.mimetype ?? null, file?.buffer.length ?? null, sha256, principal.userId, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_REPORT_FOLLOW_UP_ADDED, entityType: 'inspection_report_follow_up', entityId: followUpId, afterData: { reportId, type: dto.type, externalReference: dto.externalReference ?? null, occurredAt, description: dto.description ?? null, originalName: file?.originalname ?? null, sha256, }, metadata: { reportCode: report.code, actId: report.actId, appendOnly: true }, }, manager); return this.listFollowUpsWithManager(manager, reportId); }); } catch (error) { if (filePath) await unlink(filePath).catch(() => undefined); throw error; } } async listFollowUps(reportId: string) { const report = await this.getReport(this.dataSource.manager, reportId); if (!report) throw reportNotFound(); return this.listFollowUpsWithManager(this.dataSource.manager, reportId); } private async listFollowUpsWithManager(manager: EntityManager, reportId: string) { return manager.query(` SELECT follow_up.id, follow_up.report_id AS "reportId", follow_up.type, follow_up.external_reference AS "externalReference", follow_up.occurred_at AS "occurredAt", follow_up.description, follow_up.original_name AS "originalName", follow_up.mime_type AS "mimeType", follow_up.size_bytes AS "sizeBytes", follow_up.sha256, follow_up.created_by AS "createdBy", follow_up.created_at AS "createdAt" FROM inspection_report_follow_ups follow_up WHERE follow_up.report_id=$1 ORDER BY follow_up.occurred_at,follow_up.created_at,follow_up.id `, [reportId]); } private reportStorageError(): InternalServerErrorException { return new InternalServerErrorException({ code: 'INSPECTION_REPORT_GEDO_PDF_STORAGE_ERROR', message: 'El PDF oficial de GEDO no está disponible o no supera la validación de integridad', }); } private assertPdf(file: UploadedInspectionReportFile | undefined): void { if (!file?.buffer?.length) { throw new BadRequestException({ code: 'GEDO_PDF_REQUIRED', message: 'Debe adjuntarse el PDF oficial generado por GEDO', }); } if (file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) { throw new BadRequestException({ code: 'GEDO_PDF_TOO_LARGE', message: 'El PDF oficial supera el máximo permitido de 40 MB', }); } if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') { throw new BadRequestException({ code: 'INVALID_GEDO_PDF', message: 'El documento oficial de GEDO debe ser un PDF válido', }); } } private safeExtension(name: string | undefined): string { if (!name) return ''; const dot = name.lastIndexOf('.'); if (dot < 0) return ''; const ext = name.slice(dot).toLowerCase(); return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : ''; } private async lockReport(manager: EntityManager, id: string): Promise { const [row] = await manager.query(` SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, executive_summary AS "executiveSummary",report_description AS "reportDescription", gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt" FROM inspection_reports WHERE id=$1 FOR UPDATE `, [id]) as ReportRow[]; if (!row) throw reportNotFound(); return row; } private async getReport(manager: EntityManager, id: string): Promise { const [row] = await manager.query(` SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, executive_summary AS "executiveSummary",report_description AS "reportDescription", gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt" FROM inspection_reports WHERE id=$1 `, [id]) as ReportRow[]; return row ?? null; } private async getWorkflowView(manager: EntityManager, id: string) { const report = await this.getReport(manager, id); if (!report) throw reportNotFound(); const [act] = await manager.query(` SELECT code,urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType", deadline_basis AS "deadlineBasis",deadline_base_at AS "deadlineBaseAt",deadline_at AS "deadlineAt" FROM inspection_acts WHERE id=$1 `, [report.actId]); return { ...report, act, followUps: await this.listFollowUpsWithManager(manager, id), }; } }