DH V2 CI / API · typecheck, tests, build (push) Successful in 43s
Production dependency audit / API · production dependencies (push) Successful in 15s
Production dependency audit / WEB · production dependencies (push) Successful in 14s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m36s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m45s
DH V2 CI / Promote verified main to deploy (push) Successful in 6s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m59s
275 lines
15 KiB
TypeScript
275 lines
15 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, 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<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');
|
|
this.evidenceRoot = resolve(config.get<string>('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings');
|
|
this.assetRoot = resolve(config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media');
|
|
this.signatureRoot = resolve(config.get<string>('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures');
|
|
}
|
|
|
|
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 = 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<string, unknown> }>;
|
|
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<ActPdfContext> {
|
|
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<Buffer> {
|
|
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<ActPdfImage[]> {
|
|
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',
|
|
});
|
|
}
|
|
}
|