fix: close final F4 review gaps

This commit is contained in:
github-actions[bot]
2026-09-08 18:22:02 +00:00
parent 969ffddc76
commit 5dc7ea037d
9 changed files with 182 additions and 137 deletions
@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4AlignAssetVersionFunctionChange1790000700000 implements MigrationInterface {
name = 'F4AlignAssetVersionFunctionChange1790000700000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_versions
DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type
`);
await queryRunner.query(`
ALTER TABLE asset_versions
ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED', 'FUNCTION_CHANGED',
'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',
'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE asset_versions
SET change_type='UPDATED'
WHERE change_type='FUNCTION_CHANGED'
`);
await queryRunner.query(`
ALTER TABLE asset_versions
DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type
`);
await queryRunner.query(`
ALTER TABLE asset_versions
ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED',
'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',
'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
))
`);
}
}
@@ -58,7 +58,7 @@ export class InspectionReportWordService {
reportDescription: row.reportDescription,
});
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}.docx`;
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 });
@@ -1,10 +1,11 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import {
BadRequestException,
ConflictException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -58,7 +59,7 @@ export class InspectionReportWorkflowService {
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
?? '/app/storage/inspection-reports';
?? '/app/storage/asset-media/inspection-reports';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute');
this.root = resolve(configured);
if (this.root === parse(this.root).root) {
@@ -97,6 +98,30 @@ export class InspectionReportWorkflowService {
UPDATE inspection_reports
SET executive_summary=$2,
report_description=$3,
word_status=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN 'PENDING' ELSE word_status END,
word_original_name=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_original_name END,
word_stored_name=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_stored_name END,
word_mime_type=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_mime_type END,
word_size_bytes=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_size_bytes END,
word_sha256=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_sha256 END,
word_generated_at=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_generated_at END,
word_error=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_error END,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [reportId, executiveSummary, reportDescription]);
@@ -195,6 +220,44 @@ export class InspectionReportWorkflowService {
}
}
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(`
SELECT
gedo_pdf_original_name AS "originalName",
gedo_pdf_stored_name AS "storedName",
gedo_pdf_mime_type AS "mimeType",
gedo_pdf_size_bytes::integer AS "sizeBytes",
gedo_pdf_sha256 AS sha256
FROM inspection_reports
WHERE id=$1
`, [reportId]) as Array<{
originalName: string | null;
storedName: string | null;
mimeType: string | null;
sizeBytes: number | null;
sha256: string | null;
}>;
if (!row) throw reportNotFound();
if (!row.originalName || !row.storedName || !row.sizeBytes || !row.sha256) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_AVAILABLE',
message: 'El PDF oficial de GEDO todavía no está disponible',
});
}
const filePath = resolve(this.root, row.storedName);
if (filePath === this.root || !filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.reportStorageError();
const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== row.sha256) throw this.reportStorageError();
return {
filePath,
originalName: row.originalName,
mimeType: row.mimeType ?? 'application/pdf',
};
}
async addFollowUp(
reportId: string,
dto: CreateInspectionReportFollowUpDto,
@@ -291,6 +354,13 @@ export class InspectionReportWorkflowService {
`, [reportId]);
}
private reportStorageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'INSPECTION_REPORT_GEDO_PDF_STORAGE_ERROR',
message: 'El PDF oficial de GEDO no está disponible o no supera la validación de integridad',
});
}
private assertPdf(file: UploadedInspectionReportFile | undefined): void {
if (!file?.buffer?.length) {
throw new BadRequestException({
@@ -68,6 +68,18 @@ export class InspectionReportsController {
return response.sendFile(content.filePath);
}
@Get(':id/gedo-pdf')
@RequirePermissions('inspection_reports.read')
async gedoPdfContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Res() response: Response,
) {
const content = await this.workflow.officialPdfContent(id);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('\"', '')}"`);
return response.sendFile(content.filePath);
}
@Patch(':id')
@RequirePermissions('inspection_reports.generate')
updateNarrative(