120 lines
4.8 KiB
TypeScript
120 lines
4.8 KiB
TypeScript
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<string>('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<void> {
|
|
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<string, unknown>;
|
|
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',
|
|
});
|
|
}
|
|
}
|