F4: include editable INF fields in generated Word

This commit is contained in:
2026-09-07 21:31:57 -03:00
parent 53702009e3
commit 0db4567289
@@ -12,6 +12,8 @@ interface WordRow {
id: string; id: string;
code: string; code: string;
title: string; title: string;
executiveSummary: string | null;
reportDescription: string | null;
generatedAt: Date; generatedAt: Date;
frozenSha256: string; frozenSha256: string;
frozenSnapshot: Record<string, unknown>; frozenSnapshot: Record<string, unknown>;
@@ -29,7 +31,8 @@ export class InspectionReportWordService {
private readonly root: string; private readonly root: string;
constructor(private readonly dataSource: DataSource, config: ConfigService) { constructor(private readonly dataSource: DataSource, config: ConfigService) {
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT') ?? '/app/storage/asset-media/inspection-reports-word'; const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
?? '/app/storage/asset-media/inspection-reports-word';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path'); if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
this.root = resolve(configured); this.root = resolve(configured);
if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root'); if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root');
@@ -38,7 +41,11 @@ export class InspectionReportWordService {
async ensure(reportId: string): Promise<void> { async ensure(reportId: string): Promise<void> {
const row = await this.load(reportId); const row = await this.load(reportId);
if (row.wordStatus === 'READY' && row.wordStoredName) { if (row.wordStatus === 'READY' && row.wordStoredName) {
try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {} try {
await this.content(row.id);
await this.ensureInitialRevision(row);
return;
} catch {}
} }
try { try {
const built = buildInspectionReportWord({ const built = buildInspectionReportWord({
@@ -47,6 +54,8 @@ export class InspectionReportWordService {
generatedAt: new Date(row.generatedAt), generatedAt: new Date(row.generatedAt),
frozenSha256: row.frozenSha256, frozenSha256: row.frozenSha256,
frozenSnapshot: row.frozenSnapshot, frozenSnapshot: row.frozenSnapshot,
executiveSummary: row.executiveSummary,
reportDescription: row.reportDescription,
}); });
await mkdir(this.root, { recursive: true, mode: 0o700 }); await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}.docx`; const storedName = `${row.id}.docx`;
@@ -79,8 +88,17 @@ export class InspectionReportWordService {
async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const row = await this.load(reportId); const row = await this.load(reportId);
if (row.wordStatus !== 'READY' || !row.wordStoredName || !row.wordOriginalName || !row.wordSha256 || !row.wordSizeBytes) { if (
throw new NotFoundException({ code: 'INSPECTION_REPORT_WORD_NOT_READY', message: 'El archivo Word del informe todavía no está disponible' }); row.wordStatus !== 'READY'
|| !row.wordStoredName
|| !row.wordOriginalName
|| !row.wordSha256
|| !row.wordSizeBytes
) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_WORD_NOT_READY',
message: 'El archivo Word del informe todavía no está disponible',
});
} }
const filePath = resolve(this.root, row.wordStoredName); const filePath = resolve(this.root, row.wordStoredName);
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError(); if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
@@ -89,19 +107,40 @@ export class InspectionReportWordService {
const buffer = await readFile(filePath); const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex'); const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== row.wordSha256) throw this.storageError(); if (sha256 !== row.wordSha256) throw this.storageError();
return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME }; return {
filePath,
originalName: row.wordOriginalName,
mimeType: row.wordMimeType ?? WORD_MIME,
};
} }
private async ensureInitialRevision(row: WordRow): Promise<void> { private async ensureInitialRevision(row: WordRow): Promise<void> {
if (row.wordStatus !== 'READY' || !row.wordOriginalName || !row.wordStoredName || !row.wordMimeType || !row.wordSizeBytes || !row.wordSha256) return; if (
row.wordStatus !== 'READY'
|| !row.wordOriginalName
|| !row.wordStoredName
|| !row.wordMimeType
|| !row.wordSizeBytes
|| !row.wordSha256
) return;
await this.dataSource.query(` await this.dataSource.query(`
INSERT INTO inspection_report_revisions ( INSERT INTO inspection_report_revisions (
report_id,revision_number,source,original_name,stored_name,mime_type, report_id,revision_number,source,original_name,stored_name,mime_type,
size_bytes,sha256,change_summary,created_by,created_at size_bytes,sha256,change_summary,created_by,created_at
) VALUES ($1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial',$7,COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)) ) VALUES (
$1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial editable',$7,
COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)
)
ON CONFLICT (report_id,revision_number) DO NOTHING ON CONFLICT (report_id,revision_number) DO NOTHING
`, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]); `, [
row.id,
row.wordOriginalName,
row.wordStoredName,
row.wordMimeType,
row.wordSizeBytes,
row.wordSha256,
row.generatedBy,
]);
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_reports UPDATE inspection_reports
SET current_revision_number=GREATEST(current_revision_number,1),updated_at=CURRENT_TIMESTAMP SET current_revision_number=GREATEST(current_revision_number,1),updated_at=CURRENT_TIMESTAMP
@@ -111,18 +150,35 @@ export class InspectionReportWordService {
private async load(reportId: string): Promise<WordRow> { private async load(reportId: string): Promise<WordRow> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256", SELECT id,code,title,
frozen_snapshot AS "frozenSnapshot", word_status AS "wordStatus", executive_summary AS "executiveSummary",
word_original_name AS "wordOriginalName", word_stored_name AS "wordStoredName", report_description AS "reportDescription",
word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes", generated_at AS "generatedAt",
word_sha256 AS "wordSha256", generated_by AS "generatedBy" frozen_sha256 AS "frozenSha256",
FROM inspection_reports WHERE id = $1 frozen_snapshot AS "frozenSnapshot",
word_status AS "wordStatus",
word_original_name AS "wordOriginalName",
word_stored_name AS "wordStoredName",
word_mime_type AS "wordMimeType",
word_size_bytes::integer AS "wordSizeBytes",
word_sha256 AS "wordSha256",
generated_by AS "generatedBy"
FROM inspection_reports
WHERE id=$1
`, [reportId]) as WordRow[]; `, [reportId]) as WordRow[];
if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); if (!row) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
return row; return row;
} }
private storageError(): InternalServerErrorException { private storageError(): InternalServerErrorException {
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', message: 'El archivo Word del informe no está disponible o no supera la validación de integridad' }); return new InternalServerErrorException({
code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR',
message: 'El archivo Word del informe no está disponible o no supera la validación de integridad',
});
} }
} }