diff --git a/.github/workflows/f4-one-shot-final-review-fix.yml b/.github/workflows/f4-one-shot-final-review-fix.yml new file mode 100644 index 0000000..5986332 --- /dev/null +++ b/.github/workflows/f4-one-shot-final-review-fix.yml @@ -0,0 +1,134 @@ +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}
PDF GEDO fijado. SHA-256: {report.gedoPdfSha256}