import { createHash } from 'node:crypto'; import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { isAbsolute, parse, resolve } from 'node:path'; import { ConfigService } from '@nestjs/config'; import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { buildInspectionActPdf, type ActPdfContext, type ActPdfImage } from './inspection-act-pdf-builder'; import { renderableInspectionImage } from './inspection-document-images'; @Injectable() export class InspectionActPdfService { private readonly root: string; private readonly evidenceRoot: string; private readonly assetRoot: string; private readonly signatureRoot: string; constructor(private readonly dataSource: DataSource, config: ConfigService) { const configured = config.get('INSPECTION_ACT_PDF_ROOT') ?? '/app/storage/asset-media/inspection-acts-pdf'; if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); this.root = resolve(configured); if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); this.evidenceRoot = resolve(config.get('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings'); this.assetRoot = resolve(config.get('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media'); this.signatureRoot = resolve(config.get('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures'); } async ensure(actId: string): Promise { const [row] = await this.dataSource.query(` SELECT act.id, act.code, act.closure_sha256 AS "closureSha256", closure.final_snapshot AS "finalSnapshot", artifact.status, artifact.stored_name AS "storedName" FROM inspection_acts act JOIN inspection_act_closures closure ON closure.act_id=act.id LEFT JOIN inspection_act_pdf_artifacts artifact ON artifact.act_id=act.id WHERE act.id=$1 AND act.status='SEALED' `, [actId]) as Array<{ id: string; code: string; closureSha256: string; finalSnapshot: Record; status: string | null; storedName: string | null; }>; if (!row) { throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_SEALED', message: 'El PDF sólo puede generarse desde un Acta SELLADA', }); } if (row.status === 'READY' && row.storedName) { try { await this.content(actId); return; } catch {} } await this.dataSource.query(` INSERT INTO inspection_act_pdf_artifacts(act_id,status) VALUES($1,'PENDING') ON CONFLICT (act_id) DO UPDATE SET status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP `, [actId]); try { const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId)); await mkdir(this.root, { recursive: true, mode: 0o700 }); const storedName = `${actId}.pdf`; const originalName = `${row.code}.pdf`; const filePath = resolve(this.root, storedName); await writeFile(filePath, built.buffer, { mode: 0o600 }); await this.dataSource.query(` UPDATE inspection_act_pdf_artifacts SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf', size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL, updated_at=CURRENT_TIMESTAMP WHERE act_id=$1 `, [actId, originalName, storedName, built.buffer.length, built.sha256]); } catch (error) { const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido'; await this.dataSource.query(` UPDATE inspection_act_pdf_artifacts SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1 `, [actId, message]).catch(() => undefined); } } // The original sealed PDF remains immutable for historic delivery and audit. // A consolidated presentation is stored separately and frozen at its first generation. async consolidatedContent(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> { const [existing] = await this.dataSource.query(` SELECT stored_name AS "storedName",original_name AS "originalName", size_bytes AS "sizeBytes",sha256 FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=3 `, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; if (existing) { const buffer = await this.verifiedImage(this.root, existing); return { buffer, originalName: existing.originalName, mimeType: 'application/pdf' }; } const [row] = await this.dataSource.query(` SELECT act.code,act.closure_sha256 AS "closureSha256",closure.final_snapshot AS "finalSnapshot" FROM inspection_acts act JOIN inspection_act_closures closure ON closure.act_id=act.id WHERE act.id=$1 AND act.status='SEALED' `, [actId]) as Array<{ code: string; closureSha256: string; finalSnapshot: Record }>; if (!row) throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_SEALED', message: 'El Acta debe estar firmada y sellada para generar el documento consolidado', }); const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId)); await mkdir(this.root, { recursive: true, mode: 0o700 }); const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`; const originalName = `${row.code}-consolidada.pdf`; await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error; const previous = await this.verifiedImage(this.root, { storedName, sizeBytes: built.buffer.length, sha256: built.sha256, }); if (!previous.equals(built.buffer)) throw this.storageError(); }); await this.dataSource.query(` INSERT INTO inspection_act_consolidated_pdf_revisions(act_id,template_version,stored_name,original_name,size_bytes,sha256) VALUES($1,3,$2,$3,$4,$5) ON CONFLICT (act_id,template_version) DO NOTHING `, [actId, storedName, originalName, built.buffer.length, built.sha256]); const [saved] = await this.dataSource.query(` SELECT stored_name AS "storedName",original_name AS "originalName", size_bytes AS "sizeBytes",sha256 FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=3 `, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; if (!saved) throw this.storageError(); return { buffer: await this.verifiedImage(this.root, saved), originalName: saved.originalName, mimeType: 'application/pdf', }; } async consolidatedRevisionContent(actId: string, version: number): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> { if (![1, 2, 3].includes(version)) throw new NotFoundException('Versión documental inexistente'); const [row] = await this.dataSource.query(` SELECT stored_name AS "storedName",original_name AS "originalName", size_bytes AS "sizeBytes",sha256 FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=$2 `, [actId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; if (!row) throw new NotFoundException('Versión documental inexistente'); return { buffer: await this.verifiedImage(this.root, row), originalName: row.originalName, mimeType: 'application/pdf' }; } private async actContext(actId: string): Promise { const [row] = await this.dataSource.query(` SELECT company.name AS "companyName",department.name AS "departmentName",area.name AS "areaName", scope.name AS "scopeName",scope.code AS "scopeCode",scope_type.name AS "scopeTypeName",scope_type.code AS "scopeTypeCode", COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.name END,yacimiento_from_findings.name) AS "yacimientoName", COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.code END,yacimiento_from_findings.code) AS "yacimientoCode", btrim(concat_ws(' ',lead.first_name,lead.last_name)) AS "leadInspectorName" FROM inspection_acts act JOIN inspection_visits visit ON visit.id=act.visit_id LEFT JOIN assets company ON company.id=visit.operator_company_id LEFT JOIN assets area ON area.id=visit.operational_area_id LEFT JOIN assets department ON department.id=area.parent_id LEFT JOIN assets scope ON scope.id=visit.scope_asset_id LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id LEFT JOIN LATERAL ( WITH RECURSIVE ancestors AS ( SELECT asset.id,asset.parent_id,asset.code,asset.name,asset.asset_type_id FROM inspection_findings finding JOIN assets asset ON asset.id=finding.asset_id WHERE finding.act_id=act.id AND finding.status<>'VOIDED' UNION SELECT parent.id,parent.parent_id,parent.code,parent.name,parent.asset_type_id FROM ancestors JOIN assets parent ON parent.id=ancestors.parent_id ) SELECT string_agg(DISTINCT ancestors.name, ', ' ORDER BY ancestors.name) AS name, string_agg(DISTINCT ancestors.code, ', ' ORDER BY ancestors.code) AS code FROM ancestors JOIN asset_types ancestor_type ON ancestor_type.id=ancestors.asset_type_id WHERE lower(ancestor_type.code)='yacimiento' ) yacimiento_from_findings ON true WHERE act.id=$1 `, [actId]) as ActPdfContext[]; return row ?? {}; } private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise { if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw new Error('Ruta inválida de evidencia del Acta'); const path = resolve(root, row.storedName); if (!path.startsWith(`${root}/`)) throw new Error('Ruta inválida de evidencia del Acta'); const file = await stat(path); if (!file.isFile() || file.size !== Number(row.sizeBytes)) throw new Error('Evidencia incompleta del Acta'); const buffer = await readFile(path); if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw new Error('Hash de evidencia distinto del registrado'); return buffer; } async fieldImages(actId: string, includeAssetPhotos = true): Promise { type ImageRow = { id: string; findingId?: string; assetId?: string; signerName?: string; title?: string; capturedAt?: Date; storedName: string; sha256: string; sizeBytes: number }; const findings = await this.dataSource.query(` SELECT evidence.id, finding.id AS "findingId", evidence.title, evidence.captured_at AS "capturedAt", evidence.stored_name AS "storedName", evidence.sha256, evidence.size_bytes AS "sizeBytes" FROM inspection_findings finding JOIN inspection_acts act ON act.id=finding.act_id JOIN inspection_finding_evidence evidence ON evidence.finding_id=finding.id WHERE act.id=$1 AND evidence.kind='PHOTO' AND evidence.purpose='OBSERVATION' AND evidence.created_at<=act.locked_at ORDER BY finding.finding_number,evidence.captured_at,evidence.id `, [actId]) as ImageRow[]; const assets = includeAssetPhotos ? await this.dataSource.query(` SELECT media.id,asset.id AS "assetId", asset.name AS title, capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName", media.sha256,media.size_bytes AS "sizeBytes" FROM inspection_acts act JOIN inspection_act_assets link ON link.act_id=act.id AND link.included=true JOIN assets asset ON asset.id=link.asset_id JOIN asset_field_capture_events capture ON capture.visit_id=act.visit_id AND capture.asset_id=asset.id AND capture.event_type='PHOTO' JOIN asset_media media ON media.id=capture.media_id AND media.deleted_at IS NULL AND media.kind='PHOTO' WHERE act.id=$1 AND capture.created_at<=act.locked_at ORDER BY capture.device_captured_at,media.id `, [actId]) as ImageRow[] : []; const signatures = await this.dataSource.query(` SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName", signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes" FROM inspection_act_signatures signature WHERE signature.act_id=$1 AND signature.status='SIGNED' AND signature.stored_name IS NOT NULL ORDER BY signature.created_at,signature.id `, [actId]) as ImageRow[]; const result: ActPdfImage[] = []; for (const row of findings) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.evidenceRoot, row)) }); for (const row of assets) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.assetRoot, row)) }); for (const row of signatures) result.push({ ...row, buffer: await this.verifiedImage(this.signatureRoot, row) }); return result; } async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> { const [row] = 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_act_pdf_artifacts WHERE act_id=$1 AND status='READY' `, [actId]) as Array<{ originalName: string; storedName: string; mimeType: string; sizeBytes: number; sha256: string; }>; if (!row) { throw new NotFoundException({ code: 'INSPECTION_ACT_PDF_NOT_READY', message: 'El PDF del Acta todavía no está disponible', }); } const filePath = resolve(this.root, row.storedName); if (!filePath.startsWith(`${this.root}/`)) throw this.storageError(); const fileStat = await stat(filePath).catch(() => null); if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.storageError(); const buffer = await readFile(filePath); if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError(); return { buffer, originalName: row.originalName, mimeType: row.mimeType }; } private storageError(): InternalServerErrorException { return new InternalServerErrorException({ code: 'INSPECTION_ACT_PDF_STORAGE_ERROR', message: 'El PDF del Acta no está disponible o no supera la validación de integridad', }); } }