import { createHash, randomUUID } from 'node:crypto'; import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { isAbsolute, parse, resolve } from 'node:path'; import { 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 { AuditAction } from '../database/entities'; import { sha256CanonicalJson } from '../inspection-closing/canonical-json'; import type { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto'; import type { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto'; import type { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto'; import { INSPECTION_REPORT_WORD_MIME, inspectInspectionReportRevisionUpload, type UploadedInspectionReportRevisionFile, validateInspectionReportRevisionContainer, } from './inspection-report-revision-file'; import { InspectionReportWordService } from './inspection-report-word.service'; export type InspectionReportReviewStatus = 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED'; export type InspectionReportRevisionSource = 'AUTO' | 'DIRECTOR_UPLOAD'; export interface InspectionReportReviewPerson { id: string; username: string; firstName: string; lastName: string; } export interface InspectionReportRevisionView { id: string; reportId: string; revisionNumber: number; source: InspectionReportRevisionSource; originalName: string; mimeType: string; sizeBytes: number; sha256: string; changeSummary: string | null; createdAt: Date; createdBy: InspectionReportReviewPerson; } export interface InspectionReportSignatureView { id: string; revisionId: string; signedAt: Date; confirmationText: string; signatureSha256: string; signedBy: InspectionReportReviewPerson; } export interface InspectionReportReviewView { reportId: string; reportCode: string; reviewStatus: InspectionReportReviewStatus; currentRevisionNumber: number; approvedRevisionId: string | null; approvedAt: Date | null; reviewNote: string | null; approvedBy: InspectionReportReviewPerson | null; signature: InspectionReportSignatureView | null; revisions: InspectionReportRevisionView[]; } interface LockedReport { id: string; code: string; status: string; frozenSha256: string; reviewStatus: InspectionReportReviewStatus; currentRevisionNumber: number; approvedRevisionId: string | null; approvedAt: Date | null; reviewNote: string | null; } interface StoredRevisionRow { id: string; reportId: string; revisionNumber: number; source: InspectionReportRevisionSource; originalName: string; storedName: string; mimeType: string; sizeBytes: number; sha256: string; } const FINAL_CONFIRMATION_TEXT = 'Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.'; @Injectable() export class InspectionReportReviewService { private readonly revisionRoot: string; constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly word: InspectionReportWordService, config: ConfigService, ) { const configured = config.get('INSPECTION_REPORT_REVISION_ROOT') ?? '/app/storage/asset-media/inspection-report-revisions'; if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_REVISION_ROOT must be an absolute path'); this.revisionRoot = resolve(configured); if (this.revisionRoot === parse(this.revisionRoot).root) throw new Error('INSPECTION_REPORT_REVISION_ROOT cannot be the filesystem root'); } async get(reportId: string): Promise { await this.word.ensure(reportId); await this.ensureAutomaticRevision(reportId); return this.loadView(reportId); } async createRevision( reportId: string, dto: CreateInspectionReportRevisionDto, file: UploadedInspectionReportRevisionFile | undefined, principal: AuthPrincipal, request: RequestWithContext, ): Promise { inspectInspectionReportRevisionUpload(file); await this.assertDirector(principal.userId, this.dataSource.manager); await mkdir(this.revisionRoot, { recursive: true, mode: 0o700 }); const storedName = `${reportId}-${randomUUID()}.docx`; const filePath = resolve(this.revisionRoot, storedName); if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError(); await writeFile(filePath, file!.buffer, { mode: 0o600 }); try { await validateInspectionReportRevisionContainer(filePath); const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); const created = await this.dataSource.transaction(async (manager) => { const report = await this.lockReport(manager, reportId); this.assertOpenForReview(report); const nextRevision = report.currentRevisionNumber + 1; const [row] = await manager.query(` INSERT INTO inspection_report_revisions ( report_id, revision_number, source, original_name, stored_name, mime_type, size_bytes, sha256, change_summary, created_by ) VALUES ($1,$2,'DIRECTOR_UPLOAD',$3,$4,$5,$6,$7,$8,$9) RETURNING id `, [ report.id, nextRevision, this.cleanOriginalName(file!.originalname), storedName, INSPECTION_REPORT_WORD_MIME, file!.buffer.length, sha256, dto.changeSummary.trim(), principal.userId, ]) as Array<{ id: string }>; await manager.query(` UPDATE inspection_reports SET current_revision_number = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 `, [report.id, nextRevision]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_REPORT_REVISION_ADDED, entityType: 'inspection_report_revision', entityId: row.id, afterData: { reportId: report.id, reportCode: report.code, revisionNumber: nextRevision, sha256, source: 'DIRECTOR_UPLOAD', }, }, manager); return row.id; }); if (!created) throw this.storageError(); } catch (error) { await rm(filePath, { force: true }).catch(() => undefined); throw error; } return this.loadView(reportId); } async approve( reportId: string, dto: ApproveInspectionReportDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { await this.dataSource.transaction(async (manager) => { await this.assertDirector(principal.userId, manager); const report = await this.lockReport(manager, reportId); this.assertOpenForReview(report); const [revision] = await manager.query(` SELECT id, revision_number AS "revisionNumber", sha256 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = $2 `, [report.id, report.currentRevisionNumber]) as Array<{ id: string; revisionNumber: number; sha256: string }>; if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_REVISION_REQUIRED', message: 'El informe necesita una versión Word válida antes de aprobarse' }); const approvedAt = new Date(); await manager.query(` UPDATE inspection_reports SET review_status = 'APPROVED', approved_revision_id = $2, approved_by = $3, approved_at = $4, review_note = $5, updated_at = CURRENT_TIMESTAMP WHERE id = $1 `, [report.id, revision.id, principal.userId, approvedAt, dto.note?.trim() || null]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_REPORT_APPROVED, entityType: 'inspection_report', entityId: report.id, afterData: { reportCode: report.code, revisionId: revision.id, revisionNumber: revision.revisionNumber, revisionSha256: revision.sha256, approvedAt: approvedAt.toISOString(), }, }, manager); }); return this.loadView(reportId); } async signFinal( reportId: string, dto: SignFinalInspectionReportDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if (dto.confirmation !== true) throw new ConflictException({ code: 'INSPECTION_REPORT_SIGNATURE_CONFIRMATION_REQUIRED', message: 'Debés confirmar expresamente la firma final del informe' }); await this.dataSource.transaction(async (manager) => { await this.assertDirector(principal.userId, manager); const report = await this.lockReport(manager, reportId); if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe ya tiene una firma final inmutable' }); if (report.reviewStatus !== 'APPROVED' || !report.approvedRevisionId || !report.approvedAt) { throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_APPROVED', message: 'El Director debe aprobar una versión antes de firmar el informe final' }); } const [revision] = await manager.query(` SELECT id, revision_number AS "revisionNumber", sha256 FROM inspection_report_revisions WHERE id = $1 AND report_id = $2 `, [report.approvedRevisionId, report.id]) as Array<{ id: string; revisionNumber: number; sha256: string }>; if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_APPROVED_REVISION_MISSING', message: 'La versión aprobada no está disponible para la firma final' }); const signedAt = new Date(); const payload = { schemaVersion: 'DH-INSPECTION-REPORT-SIGNATURE-V1', reportId: report.id, reportCode: report.code, reportFrozenSha256: report.frozenSha256, revisionId: revision.id, revisionNumber: revision.revisionNumber, revisionSha256: revision.sha256, signedBy: principal.userId, signedByUsername: principal.username, signedAt: signedAt.toISOString(), confirmationText: FINAL_CONFIRMATION_TEXT, }; const signatureSha256 = sha256CanonicalJson(payload); const [signature] = await manager.query(` INSERT INTO inspection_report_signatures ( report_id, revision_id, signed_by, signed_at, confirmation_text, signature_payload, signature_sha256 ) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id `, [report.id, revision.id, principal.userId, signedAt, FINAL_CONFIRMATION_TEXT, payload, signatureSha256]) as Array<{ id: string }>; await manager.query(` UPDATE inspection_reports SET review_status = 'SIGNED', signed_at = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 `, [report.id, signedAt]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_REPORT_SIGNED, entityType: 'inspection_report_signature', entityId: signature.id, afterData: { reportId: report.id, reportCode: report.code, revisionId: revision.id, revisionNumber: revision.revisionNumber, revisionSha256: revision.sha256, signatureSha256, signedAt: signedAt.toISOString(), }, }, manager); }); return this.loadView(reportId); } async revisionContent(revisionId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { const [revision] = await this.dataSource.query(` SELECT id, report_id AS "reportId", revision_number AS "revisionNumber", source, original_name AS "originalName", stored_name AS "storedName", mime_type AS "mimeType", size_bytes::integer AS "sizeBytes", sha256 FROM inspection_report_revisions WHERE id = $1 `, [revisionId]) as StoredRevisionRow[]; if (!revision) throw new NotFoundException({ code: 'INSPECTION_REPORT_REVISION_NOT_FOUND', message: 'Versión del informe no encontrada' }); if (revision.source === 'AUTO') return this.word.content(revision.reportId); const filePath = resolve(this.revisionRoot, revision.storedName); if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError(); const fileStat = await stat(filePath).catch(() => null); if (!fileStat?.isFile() || fileStat.size !== revision.sizeBytes) throw this.storageError(); const buffer = await readFile(filePath); const sha256 = createHash('sha256').update(buffer).digest('hex'); if (sha256 !== revision.sha256) throw this.storageError(); return { filePath, originalName: revision.originalName, mimeType: revision.mimeType }; } private async ensureAutomaticRevision(reportId: string): Promise { await this.dataSource.query(` INSERT INTO inspection_report_revisions ( report_id, revision_number, source, original_name, stored_name, mime_type, size_bytes, sha256, change_summary, created_by, created_at ) SELECT id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type, word_size_bytes, word_sha256, 'Versión automática inicial', generated_by, COALESCE(word_generated_at, generated_at) FROM inspection_reports WHERE id = $1 AND word_status = 'READY' AND word_original_name IS NOT NULL AND word_stored_name IS NOT NULL AND word_mime_type = $2 AND word_size_bytes > 0 AND word_sha256 ~ '^[0-9a-f]{64}$' ON CONFLICT (report_id, revision_number) DO NOTHING `, [reportId, INSPECTION_REPORT_WORD_MIME]); await this.dataSource.query(` UPDATE inspection_reports SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND EXISTS (SELECT 1 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = 1) `, [reportId]); } private async loadView(reportId: string): Promise { const [report] = await this.dataSource.query(` SELECT report.id AS "reportId", report.code AS "reportCode", report.review_status AS "reviewStatus", report.current_revision_number AS "currentRevisionNumber", report.approved_revision_id AS "approvedRevisionId", report.approved_at AS "approvedAt", report.review_note AS "reviewNote", CASE WHEN approver.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', approver.id, 'username', approver.username, 'firstName', approver.first_name, 'lastName', approver.last_name ) END AS "approvedBy" FROM inspection_reports report LEFT JOIN users approver ON approver.id = report.approved_by WHERE report.id = $1 `, [reportId]) as Array>; if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); const revisions = await this.dataSource.query(` SELECT revision.id, revision.report_id AS "reportId", revision.revision_number AS "revisionNumber", revision.source, revision.original_name AS "originalName", revision.mime_type AS "mimeType", revision.size_bytes::integer AS "sizeBytes", revision.sha256, revision.change_summary AS "changeSummary", revision.created_at AS "createdAt", JSONB_BUILD_OBJECT( 'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name ) AS "createdBy" FROM inspection_report_revisions revision JOIN users creator ON creator.id = revision.created_by WHERE revision.report_id = $1 ORDER BY revision.revision_number DESC `, [reportId]) as InspectionReportRevisionView[]; const [signature] = await this.dataSource.query(` SELECT signature.id, signature.revision_id AS "revisionId", signature.signed_at AS "signedAt", signature.confirmation_text AS "confirmationText", signature.signature_sha256 AS "signatureSha256", JSONB_BUILD_OBJECT( 'id', signer.id, 'username', signer.username, 'firstName', signer.first_name, 'lastName', signer.last_name ) AS "signedBy" FROM inspection_report_signatures signature JOIN users signer ON signer.id = signature.signed_by WHERE signature.report_id = $1 `, [reportId]) as InspectionReportSignatureView[]; return { ...report, signature: signature ?? null, revisions }; } private async lockReport(manager: EntityManager, reportId: string): Promise { const [report] = await manager.query(` SELECT id, code, status, frozen_sha256 AS "frozenSha256", review_status AS "reviewStatus", current_revision_number AS "currentRevisionNumber", approved_revision_id AS "approvedRevisionId", approved_at AS "approvedAt", review_note AS "reviewNote" FROM inspection_reports WHERE id = $1 FOR UPDATE `, [reportId]) as LockedReport[]; if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); if (report.status !== 'FROZEN') throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_REVIEWABLE', message: 'Sólo los informes congelados pueden ingresar al circuito de revisión' }); return report; } private assertOpenForReview(report: LockedReport): void { if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe firmado es definitivo y no admite nuevas versiones' }); if (report.reviewStatus === 'APPROVED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_APPROVED', message: 'La versión ya fue aprobada y sólo resta la firma final del Director' }); } private async assertDirector(userId: string, manager: EntityManager): Promise { const [role] = await manager.query(` SELECT role.code FROM user_roles membership JOIN roles role ON role.id = membership.role_id WHERE membership.user_id = $1 AND role.code = 'director' LIMIT 1 `, [userId]) as Array<{ code: string }>; if (!role) throw new ForbiddenException({ code: 'INSPECTION_REPORT_DIRECTOR_REQUIRED', message: 'Esta operación está reservada al Director de Hidrocarburos' }); } private cleanOriginalName(value: string): string { const cleaned = value.replace(/[\\/\0\r\n]/g, '_').trim(); return (cleaned || 'informe-corregido.docx').slice(0, 255); } private storageError(): InternalServerErrorException { return new InternalServerErrorException({ code: 'INSPECTION_REPORT_REVISION_STORAGE_ERROR', message: 'La versión del informe no está disponible o no supera la validación de integridad' }); } }