From 5dc7ea037d8e68105231707de1e6dbf8cad8b65f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:22:02 +0000 Subject: [PATCH] fix: close final F4 review gaps --- .../f4-one-shot-final-review-fix.yml | 134 ------------------ ...-f4-align-asset-version-function-change.ts | 44 ++++++ .../inspection-report-word.service.ts | 2 +- .../inspection-report-workflow.service.ts | 74 +++++++++- .../inspection-reports.controller.ts | 12 ++ .../unit/f4-final-review-contract.test.ts | 46 ++++++ docker-compose.yml | 1 + web-v2/src/lib/reportWorkflowApi.ts | 4 + web-v2/src/pages/ReportDetailPage.tsx | 2 + 9 files changed, 182 insertions(+), 137 deletions(-) delete mode 100644 .github/workflows/f4-one-shot-final-review-fix.yml create mode 100644 api-v3/src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts create mode 100644 api-v3/test/unit/f4-final-review-contract.test.ts diff --git a/.github/workflows/f4-one-shot-final-review-fix.yml b/.github/workflows/f4-one-shot-final-review-fix.yml deleted file mode 100644 index 5986332..0000000 --- a/.github/workflows/f4-one-shot-final-review-fix.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: F4 one-shot final review fixes - -on: - push: - branches: - - feature/f4-backend-saneamiento-documental - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/f4-backend-saneamiento-documental - - - name: Apply final audited F4 corrections - run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - file = Path(path) - text = file.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected exactly one match, got {count}") - file.write_text(text.replace(old, new)) - - workflow = 'api-v3/src/inspection-reports/inspection-report-workflow.service.ts' - replace_once( - workflow, - "import { mkdir, unlink, writeFile } from 'node:fs/promises';", - "import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';", - ) - replace_once( - workflow, - """ ConflictException,\n Injectable,\n NotFoundException,\n""", - """ ConflictException,\n Injectable,\n InternalServerErrorException,\n NotFoundException,\n""", - ) - replace_once( - workflow, - "?? '/app/storage/inspection-reports';", - "?? '/app/storage/asset-media/inspection-reports';", - ) - replace_once( - workflow, - """ await manager.query(`\n UPDATE inspection_reports\n SET executive_summary=$2,\n report_description=$3,\n updated_at=CURRENT_TIMESTAMP\n WHERE id=$1\n `, [reportId, executiveSummary, reportDescription]);\n""", - """ await manager.query(`\n UPDATE inspection_reports\n SET executive_summary=$2,\n report_description=$3,\n word_status=CASE\n WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3\n THEN 'PENDING' ELSE word_status END,\n word_original_name=CASE\n WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3\n THEN NULL ELSE word_original_name END,\n word_stored_name=CASE\n WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3\n THEN NULL ELSE word_stored_name END,\n word_mime_type=CASE\n WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3\n THEN NULL ELSE word_mime_type END,\n word_size_bytes=CASE\n WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3\n THEN NULL ELSE word_size_bytes END,\n word_sha256=CASE\n WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3\n THEN NULL ELSE word_sha256 END,\n word_generated_at=CASE\n WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3\n THEN NULL ELSE word_generated_at END,\n word_error=CASE\n WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3\n THEN NULL ELSE word_error END,\n updated_at=CURRENT_TIMESTAMP\n WHERE id=$1\n `, [reportId, executiveSummary, reportDescription]);\n""", - ) - replace_once( - workflow, - """ async addFollowUp(\n""", - """ async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {\n const [row] = await this.dataSource.query(`\n SELECT\n gedo_pdf_original_name AS \"originalName\",\n gedo_pdf_stored_name AS \"storedName\",\n gedo_pdf_mime_type AS \"mimeType\",\n gedo_pdf_size_bytes::integer AS \"sizeBytes\",\n gedo_pdf_sha256 AS sha256\n FROM inspection_reports\n WHERE id=$1\n `, [reportId]) as Array<{\n originalName: string | null;\n storedName: string | null;\n mimeType: string | null;\n sizeBytes: number | null;\n sha256: string | null;\n }>;\n if (!row) throw reportNotFound();\n if (!row.originalName || !row.storedName || !row.sizeBytes || !row.sha256) {\n throw new NotFoundException({\n code: 'INSPECTION_REPORT_GEDO_PDF_NOT_AVAILABLE',\n message: 'El PDF oficial de GEDO todavía no está disponible',\n });\n }\n const filePath = resolve(this.root, row.storedName);\n if (filePath === this.root || !filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();\n const fileStat = await stat(filePath).catch(() => null);\n if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.reportStorageError();\n const buffer = await readFile(filePath);\n const sha256 = createHash('sha256').update(buffer).digest('hex');\n if (sha256 !== row.sha256) throw this.reportStorageError();\n return {\n filePath,\n originalName: row.originalName,\n mimeType: row.mimeType ?? 'application/pdf',\n };\n }\n\n async addFollowUp(\n""", - ) - replace_once( - workflow, - """ private assertPdf(file: UploadedInspectionReportFile | undefined): void {\n""", - """ private reportStorageError(): InternalServerErrorException {\n return new InternalServerErrorException({\n code: 'INSPECTION_REPORT_GEDO_PDF_STORAGE_ERROR',\n message: 'El PDF oficial de GEDO no está disponible o no supera la validación de integridad',\n });\n }\n\n private assertPdf(file: UploadedInspectionReportFile | undefined): void {\n""", - ) - - word = 'api-v3/src/inspection-reports/inspection-report-word.service.ts' - replace_once( - word, - "const storedName = `${row.id}.docx`;", - "const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;", - ) - - compose = 'docker-compose.yml' - replace_once( - compose, - """ INSPECTION_REPORT_WORD_ROOT: /app/storage/asset-media/inspection-reports-word\n""", - """ INSPECTION_REPORT_UPLOAD_ROOT: /app/storage/asset-media/inspection-reports\n INSPECTION_REPORT_WORD_ROOT: /app/storage/asset-media/inspection-reports-word\n""", - ) - - controller = 'api-v3/src/inspection-reports/inspection-reports.controller.ts' - replace_once( - controller, - """ @Patch(':id')\n""", - """ @Get(':id/gedo-pdf')\n @RequirePermissions('inspection_reports.read')\n async gedoPdfContent(\n @Param('id', new ParseUUIDPipe({ version: '4' })) id: string,\n @Res() response: Response,\n ) {\n const content = await this.workflow.officialPdfContent(id);\n response.setHeader('Content-Type', content.mimeType);\n response.setHeader('Content-Disposition', `attachment; filename=\"${content.originalName.replaceAll('\\\"', '')}\"`);\n return response.sendFile(content.filePath);\n }\n\n @Patch(':id')\n""", - ) - - web_api = 'web-v2/src/lib/reportWorkflowApi.ts' - replace_once( - web_api, - """export function inspectionReportWordDownloadUrl(id: string) {\n return `/api/v3/inspection-reports/${id}/word`;\n}\n""", - """export function inspectionReportWordDownloadUrl(id: string) {\n return `/api/v3/inspection-reports/${id}/word`;\n}\n\nexport function inspectionReportGedoPdfDownloadUrl(id: string) {\n return `/api/v3/inspection-reports/${id}/gedo-pdf`;\n}\n""", - ) - - page = 'web-v2/src/pages/ReportDetailPage.tsx' - replace_once( - page, - """ getInspectionReportF4,\n inspectionReportWordDownloadUrl,\n""", - """ getInspectionReportF4,\n inspectionReportGedoPdfDownloadUrl,\n inspectionReportWordDownloadUrl,\n""", - ) - replace_once( - page, - """ {report.gedoPdfSha256 &&

PDF GEDO fijado. SHA-256: {report.gedoPdfSha256}

}\n""", - """ {report.gedoPdfOriginalName &&
Descargar PDF oficial
}\n {report.gedoPdfSha256 &&

PDF GEDO fijado. SHA-256: {report.gedoPdfSha256}

}\n""", - ) - - migration = Path('api-v3/src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts') - if migration.exists(): - raise SystemExit(f'{migration}: file already exists') - migration.write_text("""import { MigrationInterface, QueryRunner } from 'typeorm';\n\nexport class F4AlignAssetVersionFunctionChange1790000700000 implements MigrationInterface {\n name = 'F4AlignAssetVersionFunctionChange1790000700000';\n\n public async up(queryRunner: QueryRunner): Promise {\n await queryRunner.query(`\n ALTER TABLE asset_versions\n DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type\n `);\n await queryRunner.query(`\n ALTER TABLE asset_versions\n ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (\n 'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED', 'FUNCTION_CHANGED',\n 'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',\n 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',\n 'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',\n 'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'\n ))\n `);\n }\n\n public async down(queryRunner: QueryRunner): Promise {\n await queryRunner.query(`\n UPDATE asset_versions\n SET change_type='UPDATED'\n WHERE change_type='FUNCTION_CHANGED'\n `);\n await queryRunner.query(`\n ALTER TABLE asset_versions\n DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type\n `);\n await queryRunner.query(`\n ALTER TABLE asset_versions\n ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (\n 'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED',\n 'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',\n 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',\n 'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',\n 'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'\n ))\n `);\n }\n}\n""") - - test_path = Path('api-v3/test/unit/f4-final-review-contract.test.ts') - if test_path.exists(): - raise SystemExit(f'{test_path}: file already exists') - test_path.write_text("""import assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport test from 'node:test';\n\nconst root = process.cwd();\nconst read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');\n\nconst migration = read('src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts');\nconst workflow = read('src/inspection-reports/inspection-report-workflow.service.ts');\nconst word = read('src/inspection-reports/inspection-report-word.service.ts');\nconst controller = read('src/inspection-reports/inspection-reports.controller.ts');\nconst compose = read('../docker-compose.yml');\nconst webApi = read('../web-v2/src/lib/reportWorkflowApi.ts');\nconst reportPage = read('../web-v2/src/pages/ReportDetailPage.tsx');\n\ntest('F4 physically permits FUNCTION_CHANGED asset versions with a reversible downgrade', () => {\n const up = migration.slice(migration.indexOf(' public async up('), migration.indexOf(' public async down('));\n const down = migration.slice(migration.indexOf(' public async down('));\n assert.match(up, /FUNCTION_CHANGED/);\n assert.match(down, /SET change_type='UPDATED'[\\s\\S]*WHERE change_type='FUNCTION_CHANGED'/);\n});\n\ntest('F4 GEDO and follow-up uploads stay under the persisted asset-media volume', () => {\n assert.match(workflow, /\\?\\? '\\/app\\/storage\\/asset-media\\/inspection-reports'/);\n assert.match(compose, /INSPECTION_REPORT_UPLOAD_ROOT: \\/app\\/storage\\/asset-media\\/inspection-reports/);\n assert.match(compose, /dhv2_asset_media:\\/app\\/storage\\/asset-media/);\n});\n\ntest('F4 narrative edits invalidate the current generated Word without overwriting its historical file', () => {\n const update = workflow.slice(workflow.indexOf(' async updateNarrative('), workflow.indexOf(' async officialize('));\n assert.match(update, /word_status=CASE[\\s\\S]*THEN 'PENDING'/);\n assert.match(update, /word_stored_name=CASE[\\s\\S]*THEN NULL/);\n assert.match(update, /word_sha256=CASE[\\s\\S]*THEN NULL/);\n assert.match(word, /built\\.sha256\\.slice\\(0, 16\\)/);\n});\n\ntest('F4 exposes the immutable official GEDO PDF through a protected integrity-checked download', () => {\n assert.match(controller, /@Get\\(':id\\/gedo-pdf'\\)[\\s\\S]*@RequirePermissions\\('inspection_reports\\.read'\\)/);\n assert.match(controller, /this\\.workflow\\.officialPdfContent\\(id\\)/);\n assert.match(workflow, /async officialPdfContent\\(/);\n assert.match(workflow, /fileStat\\.size !== row\\.sizeBytes/);\n assert.match(workflow, /createHash\\('sha256'\\)\\.update\\(buffer\\)\\.digest\\('hex'\\)/);\n assert.match(webApi, /inspectionReportGedoPdfDownloadUrl/);\n assert.match(reportPage, /Descargar PDF oficial/);\n});\n""") - - helper = Path('.github/workflows/f4-one-shot-final-review-fix.yml') - if not helper.exists(): - raise SystemExit('one-shot helper workflow is missing') - helper.unlink() - PY - - git diff --check - - - name: Commit final audited corrections - run: | - set -Eeuo pipefail - if git diff --quiet; then - echo 'No changes produced; refusing silent success.' - exit 1 - fi - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'fix: close final F4 review gaps' - git push origin HEAD:feature/f4-backend-saneamiento-documental diff --git a/api-v3/src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts b/api-v3/src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts new file mode 100644 index 0000000..7ff4bfb --- /dev/null +++ b/api-v3/src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F4AlignAssetVersionFunctionChange1790000700000 implements MigrationInterface { + name = 'F4AlignAssetVersionFunctionChange1790000700000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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' + )) + `); + } +} diff --git a/api-v3/src/inspection-reports/inspection-report-word.service.ts b/api-v3/src/inspection-reports/inspection-report-word.service.ts index be0a9dd..fd1f559 100644 --- a/api-v3/src/inspection-reports/inspection-report-word.service.ts +++ b/api-v3/src/inspection-reports/inspection-report-word.service.ts @@ -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 }); diff --git a/api-v3/src/inspection-reports/inspection-report-workflow.service.ts b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts index 1ef9d01..fbe9602 100644 --- a/api-v3/src/inspection-reports/inspection-report-workflow.service.ts +++ b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts @@ -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('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({ diff --git a/api-v3/src/inspection-reports/inspection-reports.controller.ts b/api-v3/src/inspection-reports/inspection-reports.controller.ts index 3f44101..0dcdaf9 100644 --- a/api-v3/src/inspection-reports/inspection-reports.controller.ts +++ b/api-v3/src/inspection-reports/inspection-reports.controller.ts @@ -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( diff --git a/api-v3/test/unit/f4-final-review-contract.test.ts b/api-v3/test/unit/f4-final-review-contract.test.ts new file mode 100644 index 0000000..46095c3 --- /dev/null +++ b/api-v3/test/unit/f4-final-review-contract.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +const migration = read('src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts'); +const workflow = read('src/inspection-reports/inspection-report-workflow.service.ts'); +const word = read('src/inspection-reports/inspection-report-word.service.ts'); +const controller = read('src/inspection-reports/inspection-reports.controller.ts'); +const compose = read('../docker-compose.yml'); +const webApi = read('../web-v2/src/lib/reportWorkflowApi.ts'); +const reportPage = read('../web-v2/src/pages/ReportDetailPage.tsx'); + +test('F4 physically permits FUNCTION_CHANGED asset versions with a reversible downgrade', () => { + const up = migration.slice(migration.indexOf(' public async up('), migration.indexOf(' public async down(')); + const down = migration.slice(migration.indexOf(' public async down(')); + assert.match(up, /FUNCTION_CHANGED/); + assert.match(down, /SET change_type='UPDATED'[\s\S]*WHERE change_type='FUNCTION_CHANGED'/); +}); + +test('F4 GEDO and follow-up uploads stay under the persisted asset-media volume', () => { + assert.match(workflow, /\?\? '\/app\/storage\/asset-media\/inspection-reports'/); + assert.match(compose, /INSPECTION_REPORT_UPLOAD_ROOT: \/app\/storage\/asset-media\/inspection-reports/); + assert.match(compose, /dhv2_asset_media:\/app\/storage\/asset-media/); +}); + +test('F4 narrative edits invalidate the current generated Word without overwriting its historical file', () => { + const update = workflow.slice(workflow.indexOf(' async updateNarrative('), workflow.indexOf(' async officialize(')); + assert.match(update, /word_status=CASE[\s\S]*THEN 'PENDING'/); + assert.match(update, /word_stored_name=CASE[\s\S]*THEN NULL/); + assert.match(update, /word_sha256=CASE[\s\S]*THEN NULL/); + assert.match(word, /built\.sha256\.slice\(0, 16\)/); +}); + +test('F4 exposes the immutable official GEDO PDF through a protected integrity-checked download', () => { + assert.match(controller, /@Get\(':id\/gedo-pdf'\)[\s\S]*@RequirePermissions\('inspection_reports\.read'\)/); + assert.match(controller, /this\.workflow\.officialPdfContent\(id\)/); + assert.match(workflow, /async officialPdfContent\(/); + assert.match(workflow, /fileStat\.size !== row\.sizeBytes/); + assert.match(workflow, /createHash\('sha256'\)\.update\(buffer\)\.digest\('hex'\)/); + assert.match(webApi, /inspectionReportGedoPdfDownloadUrl/); + assert.match(reportPage, /Descargar PDF oficial/); +}); diff --git a/docker-compose.yml b/docker-compose.yml index fb61c3c..0864444 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,7 @@ services: ASSET_IMPORT_ROOT: /app/storage/asset-media/imports INSPECTION_EVIDENCE_ROOT: /app/storage/asset-media/inspection-findings INSPECTION_SIGNATURE_ROOT: /app/storage/asset-media/inspection-signatures + INSPECTION_REPORT_UPLOAD_ROOT: /app/storage/asset-media/inspection-reports INSPECTION_REPORT_WORD_ROOT: /app/storage/asset-media/inspection-reports-word INSPECTION_REPORT_REVISION_ROOT: /app/storage/asset-media/inspection-report-revisions INSPECTION_ACT_PDF_ROOT: /app/storage/asset-media/inspection-acts-pdf diff --git a/web-v2/src/lib/reportWorkflowApi.ts b/web-v2/src/lib/reportWorkflowApi.ts index b4cf29c..e73d361 100644 --- a/web-v2/src/lib/reportWorkflowApi.ts +++ b/web-v2/src/lib/reportWorkflowApi.ts @@ -201,3 +201,7 @@ export function addInspectionReportFollowUp( export function inspectionReportWordDownloadUrl(id: string) { return `/api/v3/inspection-reports/${id}/word`; } + +export function inspectionReportGedoPdfDownloadUrl(id: string) { + return `/api/v3/inspection-reports/${id}/gedo-pdf`; +} diff --git a/web-v2/src/pages/ReportDetailPage.tsx b/web-v2/src/pages/ReportDetailPage.tsx index 107c8e1..5071f2e 100644 --- a/web-v2/src/pages/ReportDetailPage.tsx +++ b/web-v2/src/pages/ReportDetailPage.tsx @@ -9,6 +9,7 @@ import { formatDate } from '../lib/format'; import { addInspectionReportFollowUp, getInspectionReportF4, + inspectionReportGedoPdfDownloadUrl, inspectionReportWordDownloadUrl, listInspectionReportFollowUps, officializeInspectionReport, @@ -228,6 +229,7 @@ export function ReportDetailPage() {
PDF oficial{report.gedoPdfOriginalName ?? 'Registrado'}
Vencimiento del Acta{formatDate(report.act.deadlineAt)}
+ {report.gedoPdfOriginalName && } {report.gedoPdfSha256 &&

PDF GEDO fijado. SHA-256: {report.gedoPdfSha256}

} : canManage && report.status === 'WORKING' ?