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 } from './inspection-act-pdf-builder'; @Injectable() export class InspectionActPdfService { private readonly root: 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'); } 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 = buildInspectionActPdf(snapshot); 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); } } 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', }); } }