Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m8s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 59s
263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
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<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, private readonly actPdf: InspectionActPdfService, 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 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<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 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<WordRow> {
|
|
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',
|
|
});
|
|
}
|
|
}
|