fix: close final F4 review gaps
This commit is contained in:
@@ -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 && <div className=\"temporal-notice\"><Icon name=\"check\" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}\n""",
|
|
||||||
""" {report.gedoPdfOriginalName && <div className=\"form-actions\"><a className=\"button secondary\" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}\n {report.gedoPdfSha256 && <div className=\"temporal-notice\"><Icon name=\"check\" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}\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<void> {\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<void> {\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
|
|
||||||
+44
@@ -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,
|
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}-${built.sha256.slice(0, 16)}.docx`;
|
||||||
const originalName = `${row.code}.docx`;
|
const originalName = `${row.code}.docx`;
|
||||||
const filePath = resolve(this.root, storedName);
|
const filePath = resolve(this.root, storedName);
|
||||||
await writeFile(filePath, built.buffer, { mode: 0o600 });
|
await writeFile(filePath, built.buffer, { mode: 0o600 });
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
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 { isAbsolute, parse, resolve } from 'node:path';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -58,7 +59,7 @@ export class InspectionReportWorkflowService {
|
|||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
) {
|
) {
|
||||||
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
|
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');
|
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute');
|
||||||
this.root = resolve(configured);
|
this.root = resolve(configured);
|
||||||
if (this.root === parse(this.root).root) {
|
if (this.root === parse(this.root).root) {
|
||||||
@@ -97,6 +98,30 @@ export class InspectionReportWorkflowService {
|
|||||||
UPDATE inspection_reports
|
UPDATE inspection_reports
|
||||||
SET executive_summary=$2,
|
SET executive_summary=$2,
|
||||||
report_description=$3,
|
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
|
updated_at=CURRENT_TIMESTAMP
|
||||||
WHERE id=$1
|
WHERE id=$1
|
||||||
`, [reportId, executiveSummary, reportDescription]);
|
`, [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(
|
async addFollowUp(
|
||||||
reportId: string,
|
reportId: string,
|
||||||
dto: CreateInspectionReportFollowUpDto,
|
dto: CreateInspectionReportFollowUpDto,
|
||||||
@@ -291,6 +354,13 @@ export class InspectionReportWorkflowService {
|
|||||||
`, [reportId]);
|
`, [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 {
|
private assertPdf(file: UploadedInspectionReportFile | undefined): void {
|
||||||
if (!file?.buffer?.length) {
|
if (!file?.buffer?.length) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ export class InspectionReportsController {
|
|||||||
return response.sendFile(content.filePath);
|
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')
|
@Patch(':id')
|
||||||
@RequirePermissions('inspection_reports.generate')
|
@RequirePermissions('inspection_reports.generate')
|
||||||
updateNarrative(
|
updateNarrative(
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -50,6 +50,7 @@ services:
|
|||||||
ASSET_IMPORT_ROOT: /app/storage/asset-media/imports
|
ASSET_IMPORT_ROOT: /app/storage/asset-media/imports
|
||||||
INSPECTION_EVIDENCE_ROOT: /app/storage/asset-media/inspection-findings
|
INSPECTION_EVIDENCE_ROOT: /app/storage/asset-media/inspection-findings
|
||||||
INSPECTION_SIGNATURE_ROOT: /app/storage/asset-media/inspection-signatures
|
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_WORD_ROOT: /app/storage/asset-media/inspection-reports-word
|
||||||
INSPECTION_REPORT_REVISION_ROOT: /app/storage/asset-media/inspection-report-revisions
|
INSPECTION_REPORT_REVISION_ROOT: /app/storage/asset-media/inspection-report-revisions
|
||||||
INSPECTION_ACT_PDF_ROOT: /app/storage/asset-media/inspection-acts-pdf
|
INSPECTION_ACT_PDF_ROOT: /app/storage/asset-media/inspection-acts-pdf
|
||||||
|
|||||||
@@ -201,3 +201,7 @@ export function addInspectionReportFollowUp(
|
|||||||
export function inspectionReportWordDownloadUrl(id: string) {
|
export function inspectionReportWordDownloadUrl(id: string) {
|
||||||
return `/api/v3/inspection-reports/${id}/word`;
|
return `/api/v3/inspection-reports/${id}/word`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function inspectionReportGedoPdfDownloadUrl(id: string) {
|
||||||
|
return `/api/v3/inspection-reports/${id}/gedo-pdf`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatDate } from '../lib/format';
|
|||||||
import {
|
import {
|
||||||
addInspectionReportFollowUp,
|
addInspectionReportFollowUp,
|
||||||
getInspectionReportF4,
|
getInspectionReportF4,
|
||||||
|
inspectionReportGedoPdfDownloadUrl,
|
||||||
inspectionReportWordDownloadUrl,
|
inspectionReportWordDownloadUrl,
|
||||||
listInspectionReportFollowUps,
|
listInspectionReportFollowUps,
|
||||||
officializeInspectionReport,
|
officializeInspectionReport,
|
||||||
@@ -228,6 +229,7 @@ export function ReportDetailPage() {
|
|||||||
<div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div>
|
<div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div>
|
||||||
<div><small>Vencimiento del Acta</small><strong>{formatDate(report.act.deadlineAt)}</strong></div>
|
<div><small>Vencimiento del Acta</small><strong>{formatDate(report.act.deadlineAt)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}
|
||||||
{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}
|
{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}
|
||||||
</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}>
|
</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
|
|||||||
Reference in New Issue
Block a user