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'; import { InspectionActPdfService } from './inspection-act-pdf.service'; const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; interface WordRow { id: string; actId: string; companyName: string | null; areaName: string | null; scopeName: string | null; code: string; title: string; executiveSummary: string | null; reportDescription: string | null; generatedAt: Date; frozenSha256: string; frozenSnapshot: Record; 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, private readonly actPdf: InspectionActPdfService, config: ConfigService) { const configured = config.get('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 { 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 photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId); const built = buildInspectionReportWord({ code: row.code, title: row.title, generatedAt: new Date(row.generatedAt), frozenSha256: row.frozenSha256, frozenSnapshot: row.frozenSnapshot, executiveSummary: row.executiveSummary, reportDescription: row.reportDescription, companyName: row.companyName, areaName: row.areaName, scopeName: row.scopeName, photos, }); await mkdir(this.root, { recursive: true, mode: 0o700 }); const storedName = `${row.id}-${built.sha256.slice(0, 16)}.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, }; } // The prior editable Word and its revision history stay available unchanged. async consolidatedContent(reportId: string): Promise<{ filePath: string; 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_report_consolidated_word_revisions WHERE report_id=$1 AND template_version=2 `, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; if (existing) return this.verifiedConsolidated(existing); const row = await this.load(reportId); const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId); const built = buildInspectionReportWord({ code: row.code, title: row.title, generatedAt: new Date(row.generatedAt), frozenSha256: row.frozenSha256, frozenSnapshot: row.frozenSnapshot, executiveSummary: row.executiveSummary, reportDescription: row.reportDescription, companyName: row.companyName, areaName: row.areaName, scopeName: row.scopeName, photos, }); await mkdir(this.root, { recursive: true, mode: 0o700 }); const storedName = `${row.id}-consolidado-${built.sha256.slice(0, 24)}.docx`; const originalName = `${row.code}-consolidado.docx`; 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.verifiedConsolidated({ storedName, originalName, sizeBytes: built.buffer.length, sha256: built.sha256, }); if (!(await readFile(previous.filePath)).equals(built.buffer)) throw this.storageError(); }); await this.dataSource.query(` INSERT INTO inspection_report_consolidated_word_revisions(report_id,template_version,stored_name,original_name,size_bytes,sha256) VALUES($1,2,$2,$3,$4,$5) ON CONFLICT (report_id,template_version) DO NOTHING `, [reportId, 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_report_consolidated_word_revisions WHERE report_id=$1 AND template_version=2 `, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; if (!saved) throw this.storageError(); return this.verifiedConsolidated(saved); } async consolidatedRevisionContent(reportId: string, version: number): Promise<{ filePath: string; originalName: string; mimeType: string }> { if (![1, 2].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_report_consolidated_word_revisions WHERE report_id=$1 AND template_version=$2 `, [reportId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; if (!row) throw new NotFoundException('Versión documental inexistente'); return this.verifiedConsolidated(row); } private async verifiedConsolidated(row: { storedName: string; originalName: string; sizeBytes: number; sha256: string }): Promise<{ filePath: string; originalName: string; mimeType: string }> { if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw this.storageError(); const filePath = resolve(this.root, row.storedName); if (!filePath.startsWith(`${this.root}/`)) throw this.storageError(); const file = await stat(filePath).catch(() => null); if (!file?.isFile() || file.size !== Number(row.sizeBytes)) throw this.storageError(); const buffer = await readFile(filePath); if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError(); return { filePath, originalName: row.originalName, mimeType: WORD_MIME }; } private async ensureInitialRevision(row: WordRow): Promise { 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 editable',$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 { const [row] = await this.dataSource.query(` SELECT report.id,report.act_id AS "actId",report.code,report.title, company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName", report.executive_summary AS "executiveSummary", report.report_description AS "reportDescription", report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256", report.frozen_snapshot AS "frozenSnapshot", report.word_status AS "wordStatus", report.word_original_name AS "wordOriginalName", report.word_stored_name AS "wordStoredName", report.word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes", report.word_sha256 AS "wordSha256", report.generated_by AS "generatedBy" FROM inspection_reports report JOIN inspection_visits visit ON visit.id=report.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 scope ON scope.id=visit.scope_asset_id WHERE report.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', }); } }