diff --git a/api-v3/src/inspection-reports/inspection-report-review.service.ts b/api-v3/src/inspection-reports/inspection-report-review.service.ts deleted file mode 100644 index c1991fd..0000000 --- a/api-v3/src/inspection-reports/inspection-report-review.service.ts +++ /dev/null @@ -1,433 +0,0 @@ -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' }); - } -}