chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { isAbsolute, parse, resolve } from 'node:path';
|
||||
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { buildInspectionReportWord } from './inspection-report-word-builder';
|
||||
|
||||
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
interface WordRow {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
generatedAt: Date;
|
||||
frozenSha256: string;
|
||||
frozenSnapshot: Record<string, unknown>;
|
||||
wordStatus: 'PENDING' | 'READY' | 'FAILED';
|
||||
wordOriginalName: string | null;
|
||||
wordStoredName: string | null;
|
||||
wordMimeType: string | null;
|
||||
wordSizeBytes: number | null;
|
||||
wordSha256: string | null;
|
||||
generatedBy: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InspectionReportWordService {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
||||
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');
|
||||
this.root = resolve(configured);
|
||||
if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root');
|
||||
}
|
||||
|
||||
async ensure(reportId: string): Promise<void> {
|
||||
const row = await this.load(reportId);
|
||||
if (row.wordStatus === 'READY' && row.wordStoredName) {
|
||||
try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {}
|
||||
}
|
||||
try {
|
||||
const built = buildInspectionReportWord({
|
||||
code: row.code,
|
||||
title: row.title,
|
||||
generatedAt: new Date(row.generatedAt),
|
||||
frozenSha256: row.frozenSha256,
|
||||
frozenSnapshot: row.frozenSnapshot,
|
||||
});
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const storedName = `${row.id}.docx`;
|
||||
const originalName = `${row.code}.docx`;
|
||||
const filePath = resolve(this.root, storedName);
|
||||
await writeFile(filePath, built.buffer, { mode: 0o600 });
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_reports
|
||||
SET word_status = 'READY',
|
||||
word_original_name = $2,
|
||||
word_stored_name = $3,
|
||||
word_mime_type = $4,
|
||||
word_size_bytes = $5,
|
||||
word_sha256 = $6,
|
||||
word_generated_at = CURRENT_TIMESTAMP,
|
||||
word_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]);
|
||||
await this.ensureInitialRevision(await this.load(row.id));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_reports
|
||||
SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [row.id, message]).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||
const row = await this.load(reportId);
|
||||
if (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);
|
||||
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
|
||||
const fileStat = await stat(filePath).catch(() => null);
|
||||
if (!fileStat?.isFile() || fileStat.size !== row.wordSizeBytes) throw this.storageError();
|
||||
const buffer = await readFile(filePath);
|
||||
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
||||
if (sha256 !== row.wordSha256) throw this.storageError();
|
||||
return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME };
|
||||
}
|
||||
|
||||
|
||||
private async ensureInitialRevision(row: WordRow): Promise<void> {
|
||||
if (row.wordStatus !== 'READY' || !row.wordOriginalName || !row.wordStoredName || !row.wordMimeType || !row.wordSizeBytes || !row.wordSha256) return;
|
||||
await this.dataSource.query(`
|
||||
INSERT INTO inspection_report_revisions (
|
||||
report_id, revision_number, source, original_name, stored_name, mime_type,
|
||||
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))
|
||||
ON CONFLICT (report_id, revision_number) DO NOTHING
|
||||
`, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]);
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_reports
|
||||
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [row.id]);
|
||||
}
|
||||
|
||||
private async load(reportId: string): Promise<WordRow> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256",
|
||||
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[];
|
||||
if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
|
||||
return row;
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user