F4: include editable INF fields in generated Word
This commit is contained in:
@@ -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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user