diff --git a/api-v3/Dockerfile b/api-v3/Dockerfile index cd51eba..461afae 100644 --- a/api-v3/Dockerfile +++ b/api-v3/Dockerfile @@ -4,6 +4,7 @@ COPY package*.json ./ RUN npm ci COPY nest-cli.json tsconfig.json ./ COPY src ./src +COPY assets ./assets RUN npm run build FROM node:24-alpine AS runner @@ -13,6 +14,7 @@ WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist +COPY --from=builder /app/assets ./assets RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage USER node EXPOSE 3000 diff --git a/api-v3/assets/fonts/DejaVuSans-Bold.ttf b/api-v3/assets/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..06db62c Binary files /dev/null and b/api-v3/assets/fonts/DejaVuSans-Bold.ttf differ diff --git a/api-v3/assets/fonts/DejaVuSans.ttf b/api-v3/assets/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..2fbbe69 Binary files /dev/null and b/api-v3/assets/fonts/DejaVuSans.ttf differ diff --git a/api-v3/assets/fonts/LICENSE.txt b/api-v3/assets/fonts/LICENSE.txt new file mode 100644 index 0000000..d1fcfc8 --- /dev/null +++ b/api-v3/assets/fonts/LICENSE.txt @@ -0,0 +1,78 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: DejaVu fonts +Upstream-Author: Stepan Roh (original author), + see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list +Source: https://dejavu-fonts.github.io/ + +Files: * +Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. + Bitstream Vera is a trademark of Bitstream, Inc. + DejaVu changes are in public domain. +License: bitstream-vera + Permission is hereby granted, free of charge, to any person obtaining a copy + of the fonts accompanying this license ("Fonts") and associated + documentation files (the "Font Software"), to reproduce and distribute the + Font Software, including without limitation the rights to use, copy, merge, + publish, distribute, and/or sell copies of the Font Software, and to permit + persons to whom the Font Software is furnished to do so, subject to the + following conditions: + . + The above copyright and trademark notices and this permission notice shall + be included in all copies of one or more of the Font Software typefaces. + . + The Font Software may be modified, altered, or added to, and in particular + the designs of glyphs or characters in the Fonts may be modified and + additional glyphs or characters may be added to the Fonts, only if the fonts + are renamed to names not containing either the words "Bitstream" or the word + "Vera". + . + This License becomes null and void to the extent applicable to Fonts or Font + Software that has been modified and is distributed under the "Bitstream + Vera" names. + . + The Font Software may be sold as part of a larger software package but no + copy of one or more of the Font Software typefaces may be sold by itself. + . + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, + TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME + FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING + ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF + THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE + FONT SOFTWARE. + . + Except as contained in this notice, the names of Gnome, the Gnome + Foundation, and Bitstream Inc., shall not be used in advertising or + otherwise to promote the sale, use or other dealings in this Font Software + without prior written authorization from the Gnome Foundation or Bitstream + Inc., respectively. For further information, contact: fonts at gnome dot + org. + +Files: debian/* +Copyright: (C) 2005-2006 Peter Cernak + (C) 2006-2011 Davide Viti + (C) 2011-2013 Christian Perrier + (C) 2013 Fabian Greffrath +License: GPL-2+ + This program is free software; you can redistribute it + and/or modify it under the terms of the GNU General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later + version. + . + This program is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + PURPOSE. See the GNU General Public License for more + details. + . + You should have received a copy of the GNU General Public + License along with this package; if not, write to the Free + Software Foundation, Inc., 51 Franklin St, Fifth Floor, + Boston, MA 02110-1301 USA + . + On Debian systems, the full text of the GNU General Public + License version 2 can be found in the file + /usr/share/common-licenses/GPL-2'. diff --git a/api-v3/assets/logo-mendoza.png b/api-v3/assets/logo-mendoza.png new file mode 100644 index 0000000..8cbccda Binary files /dev/null and b/api-v3/assets/logo-mendoza.png differ diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index e1c2159..18af583 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-8", + "version": "0.29.0-9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-8", + "version": "0.29.0-9", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", @@ -21,9 +21,11 @@ "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", "helmet": "^8.0.0", + "pdfkit": "^0.20.2", "pg": "^8.0.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0", + "sharp": "^0.35.4", "typeorm": "^0.3.0" }, "devDependencies": { @@ -32,6 +34,7 @@ "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.3", "@types/node": "^24.0.0", + "@types/pdfkit": "^0.17.6", "ts-node": "^10.9.2", "tsx": "^4.20.6", "typescript": "^5.9.0" @@ -216,6 +219,16 @@ "node": ">=12" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -664,6 +677,506 @@ "node": ">=18" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -1459,6 +1972,30 @@ "typeorm": "^0.3.0 || ^1.0.0-dev" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -1484,6 +2021,15 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -1659,6 +2205,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -2185,6 +2741,15 @@ "concat-map": "0.0.1" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -2805,6 +3370,21 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -3156,7 +3736,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -3189,6 +3768,12 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-type": { "version": "21.3.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", @@ -3228,6 +3813,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/fontkit/node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3906,6 +4517,25 @@ "integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==", "license": "MIT" }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4388,6 +5018,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4475,6 +5111,20 @@ "node": ">=8" } }, + "node_modules/pdfkit": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz", + "integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0", + "fflate": "^0.8.3", + "fontkit": "^2.0.4", + "linebreak": "^1.1.0", + "png-js": "^2.0.0" + } + }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", @@ -4594,6 +5244,14 @@ "node": ">=4" } }, + "node_modules/png-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", + "integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4793,6 +5451,12 @@ "dev": true, "license": "ISC" }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -4997,6 +5661,55 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5437,6 +6150,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -5887,6 +6606,26 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/api-v3/package.json b/api-v3/package.json index 8b9e3bf..0c80965 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-8", + "version": "0.29.0-9", "private": true, "license": "UNLICENSED", "scripts": { @@ -28,9 +28,11 @@ "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", "helmet": "^8.0.0", + "pdfkit": "^0.20.2", "pg": "^8.0.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0", + "sharp": "^0.35.4", "typeorm": "^0.3.0" }, "devDependencies": { @@ -39,6 +41,7 @@ "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.3", "@types/node": "^24.0.0", + "@types/pdfkit": "^0.17.6", "ts-node": "^10.9.2", "tsx": "^4.20.6", "typescript": "^5.9.0" diff --git a/api-v3/src/act-administration/act-administration.controller.ts b/api-v3/src/act-administration/act-administration.controller.ts index d5cca5c..9ed4b92 100644 --- a/api-v3/src/act-administration/act-administration.controller.ts +++ b/api-v3/src/act-administration/act-administration.controller.ts @@ -1,14 +1,9 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; +import { Controller, Get, Param, ParseUUIDPipe, Query, Res } from '@nestjs/common'; import type { Response } from 'express'; -import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; -import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service'; -import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto'; +import { ActAdministrationService } from './act-administration.service'; import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto'; -import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto'; @Controller('act-administration') export class ActAdministrationQueueController { @@ -21,11 +16,8 @@ export class ActAdministrationQueueController { export class ActAdministrationController { constructor(private readonly administration: ActAdministrationService) {} @Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); } - @Patch('deadline') @RequirePermissions('inspection_findings.follow_up') - setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); } - @Post('responses') @RequirePermissions('inspection_findings.follow_up') - @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } })) - addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); } + // Historic deadlines and responses remain readable here. + // New administrative entries are recorded on the related Informe. } @Controller('act-company-responses') diff --git a/api-v3/src/database/migrations/1790131800000-f6-9-consolidated-act-document.ts b/api-v3/src/database/migrations/1790131800000-f6-9-consolidated-act-document.ts new file mode 100644 index 0000000..e6baaa1 --- /dev/null +++ b/api-v3/src/database/migrations/1790131800000-f6-9-consolidated-act-document.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F69ConsolidatedActDocument1790131800000 implements MigrationInterface { + name = 'F69ConsolidatedActDocument1790131800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inspection_act_consolidated_pdf_artifacts ( + act_id uuid PRIMARY KEY REFERENCES inspection_acts(id) ON DELETE CASCADE, + stored_name varchar(255) NOT NULL, + original_name varchar(255) NOT NULL, + size_bytes integer NOT NULL CHECK (size_bytes > 0), + sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + await queryRunner.query(` + CREATE TABLE inspection_report_consolidated_word_artifacts ( + report_id uuid PRIMARY KEY REFERENCES inspection_reports(id) ON DELETE CASCADE, + stored_name varchar(255) NOT NULL, + original_name varchar(255) NOT NULL, + size_bytes integer NOT NULL CHECK (size_bytes > 0), + sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE inspection_report_consolidated_word_artifacts'); + await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_artifacts'); + } +} diff --git a/api-v3/src/inspection-findings/inspection-findings.service.ts b/api-v3/src/inspection-findings/inspection-findings.service.ts index 9ceb2d9..af17196 100644 --- a/api-v3/src/inspection-findings/inspection-findings.service.ts +++ b/api-v3/src/inspection-findings/inspection-findings.service.ts @@ -629,6 +629,13 @@ export class InspectionFindingsService { if (Object.keys(dto).length === 0) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); } + if (dto.companyResponse !== undefined || dto.companyResponseReceivedOn !== undefined + || dto.companyCommittedCorrectionOn !== undefined) { + throw new ConflictException({ + code: 'INSPECTION_FINDING_COMPANY_RESPONSE_IN_REPORT', + message: 'La respuesta de la empresa se registra en el Informe relacionado, conservando este Hallazgo como antecedente.', + }); + } return this.dataSource.transaction(async (manager) => { const finding = await this.lockFinding(manager, id); this.assertFindingOpen(finding); diff --git a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts index b3126c8..9873b6a 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -1,214 +1,131 @@ import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import PDFDocument from 'pdfkit'; -function asRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : {}; +export interface ActPdfImage { + id: string; + findingId?: string; + assetId?: string; + signerName?: string; + title?: string; + capturedAt?: string | Date | null; + sha256: string; + buffer: Buffer; } +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} function asArray(value: unknown): Array> { return Array.isArray(value) ? value.map(asRecord) : []; } - -function text(value: unknown, fallback = '-'): string { - const out = String(value ?? '').trim(); - return out || fallback; +function text(value: unknown, fallback = ''): string { + return String(value ?? '').trim() || fallback; } - function date(value: unknown): string { const parsed = new Date(String(value ?? '')); - return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-'; + return Number.isFinite(parsed.getTime()) ? parsed.toLocaleString('es-AR', { timeZone: 'America/Argentina/Mendoza', dateStyle: 'short', timeStyle: 'short' }) : '-'; +} +function isPlaceholder(value: unknown): boolean { + return text(value).startsWith('Acta de inspección en curso. Los Hallazgos'); } -function clean(value: string): string { - return value - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[–—]/g, '-') - .replace(/[“”]/g, '"') - .replace(/[‘’]/g, "'") - .replace(/[^\x20-\xFF]/g, '?'); -} - -function escapePdf(value: string): string { - return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)'); -} - -function wrap(value: string, max = 92): string[] { - const words = clean(value).split(/\s+/).filter(Boolean); - const out: string[] = []; - let line = ''; - for (const word of words) { - const next = line ? `${line} ${word}` : word; - if (next.length > max && line) { - out.push(line); - line = word; - } else { - line = next; - } - } - if (line) out.push(line); - return out.length ? out : ['-']; -} - -function lockedSnapshot(snapshot: Record): { - locked: Record; - signatures: Array>; - finalSha256: unknown; -} { +// %PDF-1.4 is the document-version contract for consolidated Actas. +export async function buildInspectionActPdf(snapshot: Record, images: ActPdfImage[] = [], context: { companyName?: string | null; areaName?: string | null; scopeName?: string | null } = {}): Promise<{ buffer: Buffer; sha256: string }> { const sealed = asRecord(snapshot); const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot); - return { - locked, - signatures: asArray(sealed.signatures), - finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256, - }; -} - -function urgencyLabel(value: unknown): string { - return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente'; -} - -function dayTypeLabel(value: unknown): string { - return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles'; -} - -function lines(snapshot: Record): string[] { - const source = lockedSnapshot(snapshot); - const locked = source.locked; const act = asRecord(locked.act); - const inspection = asRecord(act.inspection ?? asRecord(act).visit); + const inspection = asRecord(act.inspection ?? act.visit); const responsible = asRecord(locked.responsible); const inventories = asArray(locked.inventories ?? locked.assets); const findings = asArray(locked.findings); - const signatures = source.signatures; - const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE'); - const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR'); + const signatures = asArray(sealed.signatures); + const hash = text(sealed.finalSha256 ?? sealed.lockedSha256); + const logo = resolve(process.cwd(), 'assets/logo-mendoza.png'); + const doc = new PDFDocument({ size: 'A4', pdfVersion: '1.4', margins: { top: 146, bottom: 80, left: 54, right: 54 }, compress: true }); + doc.registerFont('body', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf')); + doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.ttf')); + const chunks: Buffer[] = []; + doc.on('data', (part: Buffer) => chunks.push(part)); + const done = new Promise((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); }); + const blue = '#162D69'; + const header = () => { + doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42); + doc.text('DIRECCIÓN DE HIDROCARBUROS', 54, 58); + if (existsSync(logo)) doc.image(logo, 474, 32, { fit: [62, 82] }); + doc.moveTo(54, 121).lineTo(540, 121).strokeColor('#BAC5DA').stroke(); + doc.y = 146; + }; + doc.on('pageAdded', header); + header(); + const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); }; + const heading = (label: string) => { need(48); doc.moveDown(1); doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase()); doc.moveDown(0.35); }; + const body = (value: unknown) => { need(22); doc.font('body').fillColor('#202939').fontSize(10.5).text(text(value, '-'), { lineGap: 3 }); doc.moveDown(0.4); }; + const label = (name: string, value: unknown) => { if (!text(value)) return; need(22); doc.font('body-bold').fillColor('#202939').fontSize(10).text(`${name}: `, { continued: true }); doc.font('body').text(text(value)); doc.moveDown(0.35); }; + const image = (entry: ActPdfImage, caption: string) => { + need(290); + const y = doc.y; + doc.image(entry.buffer, 58, y, { fit: [470, 245] }); + doc.y = y + 250; + doc.font('body').fontSize(8).fillColor('#47536A').text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 }); + doc.moveDown(0.5); + }; - const deadlineText = act.deadlineAt - ? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})` - : act.deadlineBasis === 'GEDO_DATE' - ? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO` - : '-'; - - const out: string[] = [ - 'ACTA DE INSPECCION', - '', - `Acta: ${text(act.code)}`, - `Inspeccion: ${text(inspection.code)}`, - `Fecha: ${date(act.occurredAt)}`, - `Urgencia: ${urgencyLabel(act.urgency)}`, - `Plazo: ${deadlineText}`, - `Representante de la empresa: ${text(responsible.fullName)}`, - `DNI: ${text(responsible.documentNumber)}`, - `Cargo / funcion: ${text(responsible.position)}`, - `Email: ${text(responsible.email)}`, - '', - 'RESUMEN', - ...wrap(text(act.summary)), - '', - 'OBSERVACIONES', - ...wrap(text(act.observations)), - '', - 'INVENTARIO INSPECCIONADO', - ]; - - if (!inventories.length) out.push('-'); - for (const item of inventories) { - out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`)); + doc.font('body-bold').fillColor('#202939').fontSize(19).text(`ACTA DE INSPECCIÓN ${text(act.code)}`); + doc.moveDown(0.5); + label('Inspección', inspection.code); + label('Empresa inspeccionada', context.companyName); + label('Área', context.areaName); + label('Yacimiento o instalación', context.scopeName); + label('Fecha y hora', date(act.occurredAt)); + label('Urgencia del Acta', text(act.urgency) === 'URGENT' ? 'Urgente' : text(act.urgency) === 'NON_URGENT' ? 'No urgente' : ''); + label('Representante de la empresa', responsible.fullName); + label('DNI', responsible.documentNumber); + label('Cargo o función', responsible.position); + heading('Lo actuado'); + if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary); + else body(`Se realizó la inspección ${text(inspection.code)}. El contenido constatado se detalla en los hallazgos registrados a continuación.`); + if (act.observations) { label('Observaciones', act.observations); } + if (inventories.length) { + heading('Instalaciones inspeccionadas'); + for (const item of inventories) body(`${text(item.name)} (${text(item.code)}) · ${text(item.typeName ?? item.typeCode)}`); } - - out.push('', 'HALLAZGOS'); - if (!findings.length) out.push('Sin hallazgos registrados.'); - for (const item of findings) { - const recurrence = item.isRecurrence - ? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}` - : ''; - out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`)); - out.push(...wrap(`Descripcion: ${text(item.description)}`)); - if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`)); + heading('Hallazgos y fotografías'); + if (!findings.length) body('No se registraron hallazgos.'); + const shownAssetPhotos = new Set(); + for (const finding of findings) { + need(75); + doc.font('body-bold').fillColor(blue).fontSize(11).text(`${text(finding.code)} · ${text(finding.title)}`); + label('Descripción', finding.description); + if (finding.legalBasis) label('Normativa consignada', finding.legalBasis); + if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`); + for (const photo of images.filter((item) => item.findingId === text(finding.id))) image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`); + for (const photo of images.filter((item) => item.assetId === text(finding.assetId))) { + if (shownAssetPhotos.has(photo.id)) continue; + shownAssetPhotos.add(photo.id); + image(photo, `Fotografía de inventario ${text(photo.title)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`); + } + doc.moveDown(0.4); } - - out.push('', 'FIRMAS Y CONSTANCIAS'); - if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.'); - for (const signature of inspectorSignatures) { - out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`)); + if (!findings.length) for (const photo of images.filter((item) => item.assetId)) image(photo, `Fotografía de inventario ${text(photo.title)}`); + heading('Intervinientes y firmas'); + for (const signature of signatures) { + const name = text(signature.signerName); + const role = text(signature.signerType) === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa'; + const status = text(signature.status); + label(role, `${name} · ${status === 'SIGNED' ? 'Firmó' : status === 'REFUSED' ? 'Se negó a firmar' : 'No firmó'} · ${date(signature.signedAt ?? signature.createdAt)}`); + if (signature.companyManifestation === 'DISSENT') label('Disconformidad', signature.companyStatement); + if (status === 'REFUSED') label('Motivo de negativa', signature.reason); + const signatureImage = images.find((item) => item.signerName === name && item.sha256 === text(signature.imageSha256)); + if (signatureImage) { need(100); const y = doc.y; doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] }); doc.y = y + 66; } } - if (!companySignature) { - out.push('Manifestacion de empresa: pendiente.'); - } else if (text(companySignature.status, '') === 'SIGNED') { - const manifestation = text(companySignature.companyManifestation, 'CONFORMITY'); - out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disconformidad' : 'Empresa: firma en conformidad'); - if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement))); - } else { - out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`)); - } - - out.push( - '', - 'INTEGRIDAD', - `Hash del Acta sellada: ${text(source.finalSha256)}`, - `Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`, - ); - return out; -} - -function objectBuffer(id: number, body: Buffer | string): Buffer { - const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1'); - return Buffer.concat([ - Buffer.from(`${id} 0 obj\n`, 'ascii'), - data, - Buffer.from('\nendobj\n', 'ascii'), - ]); -} - -export function buildInspectionActPdf(snapshot: Record): { buffer: Buffer; sha256: string } { - const all = lines(snapshot); - const chunks: Array = []; - for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56)); - if (!chunks.length) chunks.push(['ACTA DE INSPECCION']); - - const pageCount = chunks.length; - const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2); - const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2); - const objects: Buffer[] = []; - objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>')); - objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`)); - objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')); - - chunks.forEach((chunk, index) => { - const content = chunk - .map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`) - .join('\n'); - const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1'); - objects.push(objectBuffer( - pageIds[index]!, - `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`, - )); - objects.push(objectBuffer( - contentIds[index]!, - Buffer.concat([ - Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'), - stream, - Buffer.from('\nendstream', 'ascii'), - ]), - )); - }); - - const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'); - const offsets: number[] = [0]; - let position = header.length; - for (const object of objects) { - offsets.push(position); - position += object.length; - } - const xrefOffset = position; - const xref = [ - `xref\n0 ${objects.length + 1}\n`, - '0000000000 65535 f \n', - ...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`), - ].join(''); - const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; - const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]); + heading('Integridad del Acta'); + body(`SHA-256 del cierre: ${hash}`); + body(`SHA-256 del contenido cerrado: ${text(sealed.lockedSha256)}`); + need(25); + doc.fontSize(8).fillColor('#637088').text(`Acta ${text(act.code)} · documento consolidado · ${date(asRecord(sealed.seal).serverSealedAt)}`, 54, doc.y); + doc.end(); + const buffer = await done; return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; } diff --git a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts index 22cef80..4d347cb 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts @@ -4,11 +4,15 @@ import { isAbsolute, parse, resolve } from 'node:path'; import { ConfigService } from '@nestjs/config'; import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { buildInspectionActPdf } from './inspection-act-pdf-builder'; +import { buildInspectionActPdf, type ActPdfImage } from './inspection-act-pdf-builder'; +import { renderableInspectionImage } from './inspection-document-images'; @Injectable() export class InspectionActPdfService { private readonly root: string; + private readonly evidenceRoot: string; + private readonly assetRoot: string; + private readonly signatureRoot: string; constructor(private readonly dataSource: DataSource, config: ConfigService) { const configured = config.get('INSPECTION_ACT_PDF_ROOT') @@ -16,6 +20,9 @@ export class InspectionActPdfService { if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); this.root = resolve(configured); if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); + this.evidenceRoot = resolve(config.get('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings'); + this.assetRoot = resolve(config.get('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media'); + this.signatureRoot = resolve(config.get('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures'); } async ensure(actId: string): Promise { @@ -59,7 +66,7 @@ export class InspectionActPdfService { `, [actId]); try { const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; - const built = buildInspectionActPdf(snapshot); + const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId), await this.actContext(actId)); await mkdir(this.root, { recursive: true, mode: 0o700 }); const storedName = `${actId}.pdf`; const originalName = `${row.code}.pdf`; @@ -82,6 +89,120 @@ export class InspectionActPdfService { } } + // The original sealed PDF remains immutable for historic delivery and audit. + // A consolidated presentation is stored separately and frozen at its first generation. + async consolidatedContent(actId: string): Promise<{ buffer: Buffer; 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_act_consolidated_pdf_artifacts WHERE act_id=$1 + `, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; + if (existing) { + const buffer = await this.verifiedImage(this.root, existing); + return { buffer, originalName: existing.originalName, mimeType: 'application/pdf' }; + } + const [row] = await this.dataSource.query(` + SELECT act.code,act.closure_sha256 AS "closureSha256",closure.final_snapshot AS "finalSnapshot" + FROM inspection_acts act + JOIN inspection_act_closures closure ON closure.act_id=act.id + WHERE act.id=$1 AND act.status='SEALED' + `, [actId]) as Array<{ code: string; closureSha256: string; finalSnapshot: Record }>; + if (!row) throw new NotFoundException({ + code: 'INSPECTION_ACT_NOT_SEALED', + message: 'El Acta debe estar firmada y sellada para generar el documento consolidado', + }); + const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; + const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId), await this.actContext(actId)); + await mkdir(this.root, { recursive: true, mode: 0o700 }); + const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`; + const originalName = `${row.code}-consolidada.pdf`; + 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.verifiedImage(this.root, { + storedName, sizeBytes: built.buffer.length, sha256: built.sha256, + }); + if (!previous.equals(built.buffer)) throw this.storageError(); + }); + await this.dataSource.query(` + INSERT INTO inspection_act_consolidated_pdf_artifacts(act_id,stored_name,original_name,size_bytes,sha256) + VALUES($1,$2,$3,$4,$5) ON CONFLICT (act_id) DO NOTHING + `, [actId, 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_act_consolidated_pdf_artifacts WHERE act_id=$1 + `, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; + if (!saved) throw this.storageError(); + return { + buffer: await this.verifiedImage(this.root, saved), + originalName: saved.originalName, mimeType: 'application/pdf', + }; + } + + private async actContext(actId: string): Promise<{ companyName: string | null; areaName: string | null; scopeName: string | null }> { + const [row] = await this.dataSource.query(` + SELECT company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName" + FROM inspection_acts act + JOIN inspection_visits visit ON visit.id=act.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 act.id=$1 + `, [actId]) as Array<{ companyName: string | null; areaName: string | null; scopeName: string | null }>; + return row ?? { companyName: null, areaName: null, scopeName: null }; + } + + private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise { + if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw new Error('Ruta inválida de evidencia del Acta'); + const path = resolve(root, row.storedName); + if (!path.startsWith(`${root}/`)) throw new Error('Ruta inválida de evidencia del Acta'); + const file = await stat(path); + if (!file.isFile() || file.size !== Number(row.sizeBytes)) throw new Error('Evidencia incompleta del Acta'); + const buffer = await readFile(path); + if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw new Error('Hash de evidencia distinto del registrado'); + return buffer; + } + + async fieldImages(actId: string): Promise { + type ImageRow = { id: string; findingId?: string; assetId?: string; signerName?: string; title?: string; capturedAt?: Date; storedName: string; sha256: string; sizeBytes: number }; + const findings = await this.dataSource.query(` + SELECT evidence.id, finding.id AS "findingId", evidence.title, + evidence.captured_at AS "capturedAt", evidence.stored_name AS "storedName", + evidence.sha256, evidence.size_bytes AS "sizeBytes" + FROM inspection_findings finding + JOIN inspection_acts act ON act.id=finding.act_id + JOIN inspection_finding_evidence evidence ON evidence.finding_id=finding.id + WHERE act.id=$1 AND evidence.kind='PHOTO' AND evidence.purpose='OBSERVATION' + AND evidence.created_at<=act.locked_at + ORDER BY finding.finding_number,evidence.captured_at,evidence.id + `, [actId]) as ImageRow[]; + const assets = await this.dataSource.query(` + SELECT media.id,asset.id AS "assetId", asset.name AS title, + capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName", + media.sha256,media.size_bytes AS "sizeBytes" + FROM inspection_acts act + JOIN inspection_act_assets link ON link.act_id=act.id AND link.included=true + JOIN assets asset ON asset.id=link.asset_id + JOIN asset_field_capture_events capture ON capture.visit_id=act.visit_id + AND capture.asset_id=asset.id AND capture.event_type='PHOTO' + JOIN asset_media media ON media.id=capture.media_id AND media.deleted_at IS NULL AND media.kind='PHOTO' + WHERE act.id=$1 AND capture.created_at<=act.locked_at + ORDER BY capture.device_captured_at,media.id + `, [actId]) as ImageRow[]; + const signatures = await this.dataSource.query(` + SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName", + signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes" + FROM inspection_act_signatures signature WHERE signature.act_id=$1 + AND signature.status='SIGNED' AND signature.stored_name IS NOT NULL + ORDER BY signature.created_at,signature.id + `, [actId]) as ImageRow[]; + const result: ActPdfImage[] = []; + for (const row of findings) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.evidenceRoot, row)) }); + for (const row of assets) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.assetRoot, row)) }); + for (const row of signatures) result.push({ ...row, buffer: await this.verifiedImage(this.signatureRoot, row) }); + return result; + } + async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> { const [row] = await this.dataSource.query(` SELECT original_name AS "originalName",stored_name AS "storedName", diff --git a/api-v3/src/inspection-reports/inspection-document-images.ts b/api-v3/src/inspection-reports/inspection-document-images.ts new file mode 100644 index 0000000..afc475f --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-document-images.ts @@ -0,0 +1,14 @@ +import sharp from 'sharp'; + +// PDFKit and the Word image renderer accept JPEG/PNG. The source bytes and +// their recorded SHA-256 remain untouched; WebP is only converted for display. +export async function renderableInspectionImage(source: Buffer): Promise { + const isWebp = source.length >= 12 + && source.subarray(0, 4).toString('ascii') === 'RIFF' + && source.subarray(8, 12).toString('ascii') === 'WEBP'; + if (!isWebp) return source; + return sharp(source) + .resize({ width: 1600, height: 1200, fit: 'inside', withoutEnlargement: true }) + .png() + .toBuffer(); +} diff --git a/api-v3/src/inspection-reports/inspection-report-word-builder.ts b/api-v3/src/inspection-reports/inspection-report-word-builder.ts index b16aba1..64e0ef5 100644 --- a/api-v3/src/inspection-reports/inspection-report-word-builder.ts +++ b/api-v3/src/inspection-reports/inspection-report-word-builder.ts @@ -1,4 +1,6 @@ import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; interface ZipEntry { name: string; @@ -13,6 +15,10 @@ interface ReportWordInput { frozenSnapshot: Record; executiveSummary?: string | null; reportDescription?: string | null; + companyName?: string | null; + areaName?: string | null; + scopeName?: string | null; + photos?: Array<{ id: string; findingId?: string; assetId?: string; title?: string; sha256: string; buffer: Buffer }>; } const crcTable = (() => { @@ -68,7 +74,7 @@ function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): st } function labelValue(label: string, value: string): string { - return `${xmlEscape(label)}: ${xmlEscape(value)}`; + return `${xmlEscape(label)}: ${xmlEscape(value)}`; } function table(headers: string[], rows: string[][]): string { @@ -102,75 +108,80 @@ function deadline(act: Record): string { return '—'; } -function documentXml(input: ReportWordInput): string { +function imageDimensions(buffer: Buffer): { width: number; height: number } { + if (buffer.subarray(0, 4).toString('hex') === '89504e47') return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; + let offset = 2; + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) break; + const marker = buffer[offset + 1]!; + if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }; + const size = buffer.readUInt16BE(offset + 2); + if (size < 2) break; + offset += size + 2; + } + return { width: 800, height: 500 }; +} + +function drawing(relationship: number, image: Buffer, name: string, maxWidth = 4572000, maxHeight = 2743200, right = false): string { + const dimensions = imageDimensions(image); + const scale = Math.min(maxWidth / dimensions.width, maxHeight / dimensions.height); + const cx = Math.round(dimensions.width * scale); + const cy = Math.round(dimensions.height * scale); + const alt = xmlEscape(name); + return `${right ? '' : ''}`; +} + +function documentXml(input: ReportWordInput, logo: Buffer): string { const snapshot = f4Snapshot(input); - const findingRows = snapshot.findings.map((item) => [ - text(item.code), - text(item.title), - text(item.description), - item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No', - item.severity == null ? '—' : `${text(item.severity)}/10`, - ]); - const inventoryRows = snapshot.inventories.map((item) => [ - text(item.code), - text(item.name), - text(item.typeName ?? item.typeCode), - ]); - const signatureRows = snapshot.signatures.map((item) => [ - text(item.signerType), - text(item.signerName), - text(item.status), - text(item.companyManifestation, ''), - isoDate(item.signedAt ?? item.createdAt), - ]); - - const executive = input.executiveSummary?.trim() - || '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.'; - const description = input.reportDescription?.trim() - || '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.'; - - const body = [ - paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'), - paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'), - labelValue('Informe', input.code), - labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)), + const photos = input.photos ?? []; + const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', '); + const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))]; + const entries: string[] = [ + paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'), + paragraph('DIRECCIÓN DE HIDROCARBUROS'), + drawing(2, logo, 'Identidad institucional Mendoza', 594360, 1005840, true), + paragraph(`Mendoza, ${input.generatedAt.toLocaleDateString('es-AR')}`), + paragraph(`INFORME TÉCNICO ${input.code}`, 'Title'), + paragraph('Sr. Director de Hidrocarburos'), + labelValue('Referencia', text(input.scopeName ?? snapshot.inventories[0]?.name, 'Instalación inspeccionada')), + labelValue('Inspector/a', authors || 'No consignado'), + labelValue('Acta', text(snapshot.source.actCode ?? snapshot.act.code)), labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)), - labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)), - labelValue('Urgencia', urgency(snapshot.act.urgency)), - labelValue('Plazo', deadline(snapshot.act)), - labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')), - paragraph('Resumen ejecutivo', 'Heading1'), - paragraph(executive), - paragraph('Descripción / análisis técnico', 'Heading1'), - paragraph(description), - paragraph('Acta fuente · contenido inmutable', 'Heading1'), - paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'), - labelValue('Resumen del Acta', text(snapshot.act.summary)), - labelValue('Observaciones del Acta', text(snapshot.act.observations)), - labelValue('Representante de la empresa', text(snapshot.responsible.fullName)), - labelValue('DNI', text(snapshot.responsible.documentNumber)), - labelValue('Cargo / función', text(snapshot.responsible.position)), - labelValue('Email', text(snapshot.responsible.email)), - paragraph('Inventario inspeccionado', 'Heading1'), - inventoryRows.length - ? table(['Código', 'Nombre', 'Tipo'], inventoryRows) - : paragraph('No se registraron elementos de Inventario en el Acta.'), - paragraph('Hallazgos', 'Heading1'), - findingRows.length - ? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows) - : paragraph('El Acta no contiene Hallazgos.'), - paragraph('Firmas y manifestaciones', 'Heading1'), - signatureRows.length - ? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows) - : paragraph('No se registraron firmas en la instantánea sellada.'), - paragraph('Integridad de la fuente', 'Heading1'), - labelValue('Hash de la fuente del INF', input.frozenSha256), - labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)), - labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)), - paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'), - ].join(''); - - return `${body}`; + labelValue('Área o Yacimiento', text(input.scopeName ?? input.areaName)), + labelValue('Empresa inspeccionada', text(input.companyName)), + paragraph('OBJETIVOS', 'Heading1'), + labelValue('General', 'Documentar los resultados de la inspección consignados en el Acta fuente.'), + labelValue('Particular', 'Analizar los hallazgos y el estado de las instalaciones inspeccionadas para definir las acciones y verificaciones que correspondan.'), + paragraph('MARCO LEGAL', 'Heading1'), + ...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]), + paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'), + paragraph(input.reportDescription?.trim() || `Según el Acta ${text(snapshot.act.code)}, se documentaron ${snapshot.findings.length} hallazgo(s) durante la inspección. Se detallan las observaciones y evidencias consignadas a continuación.`), + paragraph('FOTOS Y HALLAZGOS', 'Heading1'), + ]; + if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.')); + for (const finding of snapshot.findings) { + entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2')); + entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name))); + entries.push(paragraph(text(finding.description))); + if (finding.legalBasis) entries.push(labelValue('Normativa', text(finding.legalBasis))); + if (finding.severity != null) entries.push(labelValue('Gravedad', `${text(finding.severity)}/10`)); + for (let index = 0; index < photos.length; index++) { + const photo = photos[index]!; + if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue; + entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title))); + entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`)); + } + } + entries.push( + paragraph('CONCLUSIONES', 'Heading1'), + paragraph(input.executiveSummary?.trim() || `La inspección registró ${snapshot.findings.length} hallazgo(s). Su seguimiento y la respuesta de la empresa se documentan en el Informe.`), + paragraph('ACTA FUENTE E INTEGRIDAD', 'Heading1'), + labelValue('Acta sellada', text(snapshot.source.actCode ?? snapshot.act.code)), + labelValue('SHA-256 del cierre del Acta', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)), + labelValue('SHA-256 de la fuente del Informe', input.frozenSha256), + ); + const body = entries.join(''); + return `${body}`; } function buildZip(entries: ZipEntry[]): Buffer { @@ -232,10 +243,13 @@ function buildZip(entries: ZipEntry[]): Buffer { } export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } { + const logo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png')); + const photos = input.photos ?? []; + const mediaRelationships = photos.map((photo, index) => ``).join(''); const entries: ZipEntry[] = [ { name: '[Content_Types].xml', - data: Buffer.from('', 'utf8'), + data: Buffer.from('', 'utf8'), }, { name: '_rels/.rels', @@ -243,13 +257,15 @@ export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buf }, { name: 'word/_rels/document.xml.rels', - data: Buffer.from('', 'utf8'), + data: Buffer.from(`${mediaRelationships}`, 'utf8'), }, { name: 'word/styles.xml', - data: Buffer.from('', 'utf8'), + data: Buffer.from('', 'utf8'), }, - { name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') }, + { name: 'word/document.xml', data: Buffer.from(documentXml(input, logo), 'utf8') }, + { name: 'word/media/logo-mendoza.png', data: logo }, + ...photos.map((photo, index) => ({ name: `word/media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}`, data: photo.buffer })), { name: 'docProps/core.xml', data: Buffer.from(`${xmlEscape(input.title)}DH InspecciónDH Inspección${input.generatedAt.toISOString()}`, 'utf8'), 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 fd1f559..840051d 100644 --- a/api-v3/src/inspection-reports/inspection-report-word.service.ts +++ b/api-v3/src/inspection-reports/inspection-report-word.service.ts @@ -5,11 +5,16 @@ import { Injectable, InternalServerErrorException, NotFoundException } from '@ne 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; @@ -30,7 +35,7 @@ interface WordRow { export class InspectionReportWordService { private readonly root: string; - constructor(private readonly dataSource: DataSource, config: ConfigService) { + constructor(private readonly dataSource: DataSource, private readonly actPdf: InspectionActPdfService, config: ConfigService) { const configured = config.get('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'); @@ -48,6 +53,7 @@ export class InspectionReportWordService { } 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, @@ -56,6 +62,10 @@ export class InspectionReportWordService { 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`; @@ -114,6 +124,56 @@ export class InspectionReportWordService { }; } + // 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_artifacts WHERE report_id=$1 + `, [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_artifacts(report_id,stored_name,original_name,size_bytes,sha256) + VALUES($1,$2,$3,$4,$5) ON CONFLICT (report_id) 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_artifacts WHERE report_id=$1 + `, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; + if (!saved) throw this.storageError(); + return this.verifiedConsolidated(saved); + } + + 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 { if ( row.wordStatus !== 'READY' @@ -150,21 +210,26 @@ export class InspectionReportWordService { private async load(reportId: string): Promise { const [row] = await this.dataSource.query(` - SELECT id,code,title, - executive_summary AS "executiveSummary", - report_description AS "reportDescription", - generated_at AS "generatedAt", - frozen_sha256 AS "frozenSha256", - frozen_snapshot AS "frozenSnapshot", - word_status AS "wordStatus", - word_original_name AS "wordOriginalName", - word_stored_name AS "wordStoredName", - word_mime_type AS "wordMimeType", + 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", - word_sha256 AS "wordSha256", - generated_by AS "generatedBy" - FROM inspection_reports - WHERE id=$1 + 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({ 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 fbe9602..f05ea68 100644 --- a/api-v3/src/inspection-reports/inspection-report-workflow.service.ts +++ b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts @@ -258,6 +258,23 @@ export class InspectionReportWorkflowService { }; } + async followUpContent(reportId: string, followUpId: string): Promise<{ filePath: string; originalName: string; mimeType: string; sizeBytes: number }> { + const [item] = await this.dataSource.query(` + SELECT original_name AS "originalName",stored_name AS "storedName", + mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 + FROM inspection_report_follow_ups WHERE report_id=$1 AND id=$2 AND stored_name IS NOT NULL + `, [reportId, followUpId]) as Array<{ originalName: string; storedName: string; mimeType: string; sizeBytes: number; sha256: string }>; + if (!item) throw new NotFoundException({ code: 'REPORT_FOLLOW_UP_FILE_NOT_FOUND', message: 'Adjunto de seguimiento inexistente' }); + if (!/^followup-[a-f0-9-]+\.[a-z0-9]+$/.test(item.storedName)) throw this.reportStorageError(); + const filePath = resolve(this.root, item.storedName); + if (!filePath.startsWith(`${this.root}/`)) throw this.reportStorageError(); + const file = await stat(filePath).catch(() => null); + if (!file?.isFile() || file.size !== item.sizeBytes) throw this.reportStorageError(); + const buffer = await readFile(filePath); + if (createHash('sha256').update(buffer).digest('hex') !== item.sha256) throw this.reportStorageError(); + return { filePath, originalName: item.originalName, mimeType: item.mimeType, sizeBytes: item.sizeBytes }; + } + async addFollowUp( reportId: string, dto: CreateInspectionReportFollowUpDto, diff --git a/api-v3/src/inspection-reports/inspection-reports.controller.ts b/api-v3/src/inspection-reports/inspection-reports.controller.ts index 72bd609..104fd16 100644 --- a/api-v3/src/inspection-reports/inspection-reports.controller.ts +++ b/api-v3/src/inspection-reports/inspection-reports.controller.ts @@ -69,6 +69,20 @@ export class InspectionReportsController { return response.sendFile(content.filePath); } + @Get(':id/consolidated-word') + @RequirePermissions('inspection_reports.read') + async consolidatedWordContent( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Res() response: Response, + ) { + const content = await this.word.consolidatedContent(id); + response.setHeader('Content-Type', content.mimeType); + response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`); + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + return response.sendFile(content.filePath); + } + @Get(':id/gedo-pdf') @RequirePermissions('inspection_reports.read') async gedoPdfContent( @@ -113,6 +127,23 @@ export class InspectionReportsController { return this.workflow.listFollowUps(id); } + @Get(':id/follow-ups/:followUpId/content') + @RequirePermissions('inspection_reports.read') + async followUpContent( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('followUpId', new ParseUUIDPipe({ version: '4' })) followUpId: string, + @Res() response: Response, + ): Promise { + const file = await this.workflow.followUpContent(id, followUpId); + const safeName = file.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); + response.setHeader('Content-Type', file.mimeType); + response.setHeader('Content-Length', String(file.sizeBytes)); + response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(file.originalName)}`); + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + await new Promise((resolveSend, rejectSend) => response.sendFile(file.filePath, (error) => error ? rejectSend(error) : resolveSend())); + } + @Post(':id/follow-ups') @RequirePermissions('inspection_reports.generate') @UseInterceptors(FileInterceptor('file', { @@ -170,3 +201,23 @@ export class InspectionActPdfController { return response.send(content.buffer); } } + +@Controller('inspection-acts/:actId/consolidated-pdf') +export class InspectionActConsolidatedPdfController { + constructor(private readonly pdf: InspectionActPdfService) {} + + @Get() + @RequirePermissions('inspection_acts.read') + async content( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Res() response: Response, + ) { + const content = await this.pdf.consolidatedContent(actId); + response.setHeader('Content-Type', content.mimeType); + response.setHeader('Content-Length', String(content.buffer.length)); + response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`); + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + return response.send(content.buffer); + } +} diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index 5b52d69..a9d6852 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -7,7 +7,7 @@ import { InspectionDeadlineAdminService } from './inspection-deadline-admin.serv import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import { InspectionReportWorkflowService } from './inspection-report-workflow.service'; import { InspectionReportWordService } from './inspection-report-word.service'; -import { InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; +import { InspectionActConsolidatedPdfController, InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; import { InspectionReportsService } from './inspection-reports.service'; import { SmtpDeliveryService } from './smtp-delivery.service'; @@ -17,6 +17,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; InspectionReportsController, InspectionActReportController, InspectionActPdfController, + InspectionActConsolidatedPdfController, InspectionDeadlineAdminController, DocumentDeliveryController, ], diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 6152001..452e462 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.29.0-8'; -export const API_PHASE = 'F6.8'; +export const API_VERSION = '0.29.0-9'; +export const API_PHASE = 'F6.9'; diff --git a/api-v3/test/unit/f4-act-web-lifecycle-contract.test.ts b/api-v3/test/unit/f4-act-web-lifecycle-contract.test.ts index 7f9de98..0979f8b 100644 --- a/api-v3/test/unit/f4-act-web-lifecycle-contract.test.ts +++ b/api-v3/test/unit/f4-act-web-lifecycle-contract.test.ts @@ -36,15 +36,14 @@ test('F4 document center and inspection detail consume the F4 act client', () => test('F4 closure UI treats sealing as the definitive act state', () => { assert.match(closurePanel, /getInspectionClosureF4/); assert.match(closurePanel, /act\.status === 'SEALED'/); - assert.match(closurePanel, /ACTA SELLADA/); - assert.match(closurePanel, /manifestación de la empresa puede completarse/); - assert.match(closurePanel, /Pendiente de evento válido/); + assert.match(closurePanel, /Participantes y firmas/); + assert.doesNotMatch(closurePanel, /Pendiente de evento válido/); assert.doesNotMatch(closurePanel, /reabrir ni cerrar el acta/); }); test('F4 presentation keeps new lifecycle labels while retaining explicit legacy readability', () => { - assert.match(presentation, /LOCKED: 'Bloqueada/); - assert.match(presentation, /SEALED: 'Sellada'/); + assert.match(presentation, /LOCKED: 'Para firmar/); + assert.match(presentation, /SEALED: 'Firmada y cerrada'/); assert.match(presentation, /READY: 'Lista para cerrar · legado'/); - assert.match(presentation, /CLOSED: 'Cerrada · legado'/); + assert.match(presentation, /CLOSED: 'Firmada y cerrada'/); }); diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index 35ca3ad..05933f8 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { API_PHASE, API_VERSION } from '../../src/version'; -test('health metadata reports the current F6.8 release', () => { - assert.equal(API_PHASE, 'F6.8'); +test('health metadata reports the current F6.9 release', () => { + assert.equal(API_PHASE, 'F6.9'); const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; assert.equal(API_VERSION, pkg.version); - assert.equal(API_VERSION, '0.29.0-8'); + assert.equal(API_VERSION, '0.29.0-9'); }); \ No newline at end of file diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts index c11580d..f234652 100644 --- a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts +++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts @@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1]; assert.equal(visibleVersion, pkg.version); - assert.match(version, /APP_PHASE\s*=\s*'F6\.8/); + assert.match(version, /APP_PHASE\s*=\s*'F6\.9/); }); test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { diff --git a/api-v3/test/unit/f6-2-csrf-layout-document-projection.test.ts b/api-v3/test/unit/f6-2-csrf-layout-document-projection.test.ts index 389ae7c..fa386de 100644 --- a/api-v3/test/unit/f6-2-csrf-layout-document-projection.test.ts +++ b/api-v3/test/unit/f6-2-csrf-layout-document-projection.test.ts @@ -40,6 +40,7 @@ test('F6.2 exposes provisional PDF data contracts for Actas and Informes', () => assert.match(projection, /Firmas y manifestaciones/); assert.match(projection, /GEDO e integridad/); assert.match(projection, /Anexo final/); - assert.match(actPage, //); - assert.match(reportPage, //); + assert.match(actPage, /getInspectionActConsolidatedPdfBlob/); + assert.doesNotMatch(actPage, /DocumentPdfProjection/); + assert.doesNotMatch(reportPage, /DocumentPdfProjection/); }); diff --git a/api-v3/test/unit/f6-8-offline-document-contract.test.ts b/api-v3/test/unit/f6-8-offline-document-contract.test.ts index 73d7b97..9fd2580 100644 --- a/api-v3/test/unit/f6-8-offline-document-contract.test.ts +++ b/api-v3/test/unit/f6-8-offline-document-contract.test.ts @@ -65,9 +65,9 @@ test('F6.8 makes the real Acta PDF and photographic record first-class in Dashbo assert.match(api('src/inspection-acts/inspection-acts.service.ts'), /capture\.visit_id=act\.visit_id/); assert.match(page, /Abrir PDF del Acta/); assert.match(page, /Descargar PDF/); - assert.match(media, /Fotos y Hallazgos/); - assert.match(media, /Evidencia fotográfica/); - assert.match(media, /Fotos tomadas durante esta inspección/); + assert.match(media, /Hallazgos del Acta/); + assert.match(media, /act-finding-photos/); + assert.match(media, /listInspectionFindingEvidence/); }); test('F6.8 presents technical families to users as installation and subinstallation types', () => { diff --git a/api-v3/test/unit/f6-9-consolidated-documents.test.ts b/api-v3/test/unit/f6-9-consolidated-documents.test.ts new file mode 100644 index 0000000..31e1026 --- /dev/null +++ b/api-v3/test/unit/f6-9-consolidated-documents.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { buildInspectionActPdf } from '../../src/inspection-reports/inspection-act-pdf-builder'; +import { buildInspectionReportWord } from '../../src/inspection-reports/inspection-report-word-builder'; +import sharp from 'sharp'; +import { renderableInspectionImage } from '../../src/inspection-reports/inspection-document-images'; + +const digest = (data: Buffer) => createHash('sha256').update(data).digest('hex'); +const photo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png')); +const locked = { + act: { code: 'ACT-TEST-01', occurredAt: '2026-09-14T18:30:00Z', + summary: 'Se inspeccionó un tanque.', inspection: { code: 'INSP-TEST-01' } }, + responsible: { fullName: 'Responsable de prueba', documentNumber: '12345678' }, + inventories: [{ id: 'asset-1', code: 'CAM-TEST-01', name: 'Tanque de prueba' }], + findings: [{ id: 'finding-1', assetId: 'asset-1', code: 'ACT-TEST-H01', + title: 'Pérdida', description: 'Se constató una pérdida visible.', severity: 3 }], +}; +const sealed = { lockedSnapshot: locked, finalSha256: 'a'.repeat(64), + lockedSha256: 'b'.repeat(64), signatures: [], seal: { serverSealedAt: '2026-09-15T18:30:00Z' } }; +const evidence = { id: 'photo-1', findingId: 'finding-1', sha256: digest(photo), buffer: photo }; + +test('F6.9 consolidated Acta is a hashed PDF 1.4 with actual evidence embedded', async () => { + const without = await buildInspectionActPdf(sealed); + const withEvidence = await buildInspectionActPdf(sealed, [evidence]); + assert.ok(withEvidence.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4'))); + assert.equal(withEvidence.sha256, digest(withEvidence.buffer)); + assert.ok(withEvidence.buffer.length > without.buffer.length + 3_000); + assert.notEqual(withEvidence.sha256, without.sha256); +}); + +test('F6.9 technical Informe embeds evidence and its institutional source hash', () => { + const input = { code: 'INF-TEST-01', title: 'Informe de prueba', generatedAt: new Date('2026-09-15'), + frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed, source: { actCode: locked.act.code } } }; + const plain = buildInspectionReportWord(input); + const withEvidence = buildInspectionReportWord({ ...input, photos: [evidence] }); + assert.equal(withEvidence.sha256, digest(withEvidence.buffer)); + assert.ok(withEvidence.buffer.subarray(0, 4).equals(Buffer.from('PK\x03\x04'))); + assert.ok(withEvidence.buffer.includes(Buffer.from('word/media/photo-1.png'))); + assert.ok(withEvidence.buffer.includes(photo)); + assert.ok(withEvidence.buffer.includes(Buffer.from('ACT-TEST-H01'))); + assert.ok(withEvidence.buffer.includes(Buffer.from('SHA-256 de la fuente del Informe'))); + assert.ok(withEvidence.buffer.length > plain.buffer.length + photo.length); + assert.ok(!withEvidence.buffer.includes(Buffer.from('[Completar'))); +}); + +test('F6.9 keeps prior sealed PDF and prior company responses available for audit', () => { + const pdf = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-act-pdf.service.ts'), 'utf8'); + const controller = readFileSync(resolve(process.cwd(), 'src/act-administration/act-administration.controller.ts'), 'utf8'); + assert.match(pdf, /inspection_act_consolidated_pdf_artifacts/); + const word = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-report-word.service.ts'), 'utf8'); + assert.match(word, /inspection_report_consolidated_word_artifacts/); + assert.match(pdf, /ON CONFLICT \(act_id\) DO NOTHING/); + assert.match(controller, /responseContent\(responseId\)/); + assert.doesNotMatch(controller, /@Post\('responses'\)/); +}); + +test('F6.9 renders WebP field photographs in both documents without changing the source hash', async () => { + const original = await sharp(photo).webp().toBuffer(); + const originalSha = digest(original); + const display = await renderableInspectionImage(original); + assert.ok(display.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))); + assert.equal(digest(original), originalSha); + const pdf = await buildInspectionActPdf(sealed, [{ ...evidence, sha256: originalSha, buffer: display }]); + assert.ok(pdf.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4'))); + const word = buildInspectionReportWord({ code: 'INF-WEBP', title: 'Informe', generatedAt: new Date(), + frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [{ ...evidence, sha256: originalSha, buffer: display }] }); + assert.ok(word.buffer.includes(display)); + assert.ok(word.buffer.includes(Buffer.from(originalSha))); +}); diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index dc72169..1a6e902 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-web", - "version": "0.23.0-5", + "version": "0.23.0-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.23.0-5", + "version": "0.23.0-6", "dependencies": { "maplibre-gl": "6.4.1", "react": "^19.0.0", diff --git a/web-v2/package.json b/web-v2/package.json index bf3df1e..e874141 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.23.0-5", + "version": "0.23.0-6", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index 89876d8..ade790b 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.23.0-5'; -export const APP_PHASE = 'F6.8 · Offline seguro y Acta documental'; +export const APP_VERSION = '0.23.0-6'; +export const APP_PHASE = 'F6.9 · Actas e informes consolidados'; diff --git a/web-v2/src/features/inspections/InspectionActMediaPanel.tsx b/web-v2/src/features/inspections/InspectionActMediaPanel.tsx index 853241b..f0e2feb 100644 --- a/web-v2/src/features/inspections/InspectionActMediaPanel.tsx +++ b/web-v2/src/features/inspections/InspectionActMediaPanel.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback'; -import { Icon } from '../../components/Icon'; import { getAssetMediaBlob, getInspectionFindingEvidenceBlob, @@ -8,142 +7,70 @@ import { listInspectionFindingEvidence, listInspectionFindings, } from '../../lib/api'; -import type { InspectionActFieldMedia, InspectionFindingEvidence } from '../../lib/api'; +import type { InspectionActFieldMedia, InspectionFinding, InspectionFindingEvidence } from '../../lib/api'; import { formatDate } from '../../lib/format'; -type ActPhoto = { - findingId: string; - findingCode: string; - findingTitle: string; - evidence: InspectionFindingEvidence; -}; +type FindingWithPhotos = { finding: InspectionFinding; photos: InspectionFindingEvidence[] }; -type AssetPhoto = InspectionActFieldMedia; - -function PhotoThumb({ photo }: { photo: ActPhoto }) { +function Photo({ id, title, caption, load }: { id: string; title: string; caption: string; load: (id: string) => Promise }) { const [url, setUrl] = useState(''); - useEffect(() => { let active = true; let objectUrl = ''; - getInspectionFindingEvidenceBlob(photo.evidence.id) - .then((blob) => { - if (!active) return; - objectUrl = URL.createObjectURL(blob); - setUrl(objectUrl); - }) - .catch(() => undefined); - return () => { - active = false; - if (objectUrl) URL.revokeObjectURL(objectUrl); - }; - }, [photo.evidence.id]); - - return
- -
- {photo.findingCode} - {photo.findingTitle} - {formatDate(photo.evidence.capturedAt || photo.evidence.createdAt)} - {photo.evidence.latitude != null && photo.evidence.longitude != null && - GPS {photo.evidence.latitude.toFixed(6)}, {photo.evidence.longitude.toFixed(6)} - } -
-
; + load(id).then((blob) => { + if (!active) return; + objectUrl = URL.createObjectURL(blob); + setUrl(objectUrl); + }).catch(() => undefined); + return () => { active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); }; + }, [id, load]); + return
+ {url ? {title} : Cargando foto…} +
{caption}
+
; } -function AssetPhotoThumb({ photo }: { photo: AssetPhoto }) { - const [url, setUrl] = useState(''); - - useEffect(() => { - let active = true; - let objectUrl = ''; - getAssetMediaBlob(photo.id) - .then((blob) => { - if (!active) return; - objectUrl = URL.createObjectURL(blob); - setUrl(objectUrl); - }) - .catch(() => undefined); - return () => { - active = false; - if (objectUrl) URL.revokeObjectURL(objectUrl); - }; - }, [photo.id]); - - return
- -
- INVENTARIO · {photo.assetCode} - {photo.assetName} - {formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)} - {photo.latitude != null && photo.longitude != null && GPS {photo.latitude.toFixed(6)}, {photo.longitude.toFixed(6)}} -
+function Finding({ item, assetPhotos }: { item: FindingWithPhotos; assetPhotos: InspectionActFieldMedia[] }) { + const { finding, photos } = item; + return
+
{finding.code}

{finding.title}

{finding.asset.name} · {finding.asset.code}

+

{finding.description}

+ {finding.legalBasis &&

Normativa: {finding.legalBasis}

} + {finding.severity != null &&

Gravedad {finding.severity}/10

} + {(photos.length > 0 || assetPhotos.length > 0) &&
+ {photos.map((photo) => )} + {assetPhotos.map((photo) => )} +
} + {photos.length === 0 && assetPhotos.length === 0 && Sin fotografías vinculadas.}
; } export function InspectionActMediaPanel({ actId }: { actId: string }) { - const [photos, setPhotos] = useState([]); - const [assetPhotos, setAssetPhotos] = useState([]); - const [findingCount, setFindingCount] = useState(0); + const [items, setItems] = useState([]); + const [assetPhotos, setAssetPhotos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - useEffect(() => { let active = true; - setLoading(true); - setError(''); - Promise.all([ - listInspectionFindings(actId), - listInspectionActFieldMedia(actId).catch(() => []), - ]) - .then(async ([findings, fieldMedia]) => { - const evidenceByFinding = await Promise.all( - findings.map(async (finding) => ({ - finding, - evidence: await listInspectionFindingEvidence(finding.id), - })), - ); + setLoading(true); setError(''); + Promise.all([listInspectionFindings(actId), listInspectionActFieldMedia(actId).catch(() => [])]) + .then(async ([findings, media]) => { + const records = await Promise.all(findings.map(async (finding) => ({ + finding, photos: (await listInspectionFindingEvidence(finding.id)).filter((evidence) => evidence.kind === 'PHOTO' && evidence.purpose === 'OBSERVATION'), + }))); if (!active) return; - setFindingCount(findings.length); - setPhotos(evidenceByFinding.flatMap(({ finding, evidence }) => - evidence.filter((item) => item.kind === 'PHOTO').map((item) => ({ - findingId: finding.id, findingCode: finding.code, findingTitle: finding.title, evidence: item, - })), - )); - setAssetPhotos(fieldMedia.filter((item) => item.kind === 'PHOTO')); + setItems(records); + setAssetPhotos(media.filter((photo) => photo.kind === 'PHOTO')); }) .catch((requestError) => active && setError(errorMessage(requestError))) .finally(() => active && setLoading(false)); return () => { active = false; }; }, [actId]); - return
-
-
- REGISTRO DE CAMPO -

Fotos y Hallazgos

-

Las fotografías del Acta se muestran directamente, vinculadas al Hallazgo que documentan.

-
- {photos.length + assetPhotos.length} foto{photos.length + assetPhotos.length === 1 ? '' : 's'} · {findingCount} hallazgo{findingCount === 1 ? '' : 's'} -
+

Hallazgos del Acta

Cada hallazgo reúne su descripción y las fotos tomadas en campo.

{items.length}
{error && {error}} - {loading ? : <> - {photos.length === 0 && assetPhotos.length === 0 && } - {photos.length > 0 &&
HALLAZGOS

Evidencia fotográfica

{photos.length}
{photos.map((photo) => )}
} - {assetPhotos.length > 0 &&
INVENTARIO DE LA INSPECCIÓN

Fotos tomadas durante esta inspección

{assetPhotos.length}
{assetPhotos.map((photo) => )}
} - } - {(photos.length > 0 || assetPhotos.length > 0) &&
Seleccioná una foto para verla a tamaño completo.
} + {loading ? : items.length ? +
{items.map((item) => photo.assetId === item.finding.asset.id)} />)}
: + }
; } diff --git a/web-v2/src/features/inspections/InspectionClosurePanel.tsx b/web-v2/src/features/inspections/InspectionClosurePanel.tsx index 50d680e..5bba72d 100644 --- a/web-v2/src/features/inspections/InspectionClosurePanel.tsx +++ b/web-v2/src/features/inspections/InspectionClosurePanel.tsx @@ -1,24 +1,17 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useAuth } from '../../auth/AuthContext'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; -import { Icon } from '../../components/Icon'; import { getInspectionSignatureBlob } from '../../lib/api'; import type { InspectionActSignature } from '../../lib/api'; -import { - getInspectionClosureF4, - type InspectionActF4, - type InspectionClosureF4, -} from '../../lib/inspectionActF4Api'; +import { getInspectionClosureF4, type InspectionActF4, type InspectionClosureF4 } from '../../lib/inspectionActF4Api'; import { formatDate } from '../../lib/format'; -function signatureStatusLabel(value: InspectionActSignature['status']): string { - if (value === 'SIGNED') return 'Firmada'; - if (value === 'REFUSED') return 'Se negó a firmar'; - return 'Ausente'; -} - -function signerTypeLabel(value: InspectionActSignature['signerType']): string { - return value === 'INSPECTOR' ? 'Inspector/a' : 'Responsable de la empresa'; +function signatureOutcome(signature: InspectionActSignature): string { + if (signature.status === 'REFUSED') return `Se negó a firmar${signature.reason ? ` · ${signature.reason}` : ''}`; + if (signature.status !== 'SIGNED') return 'No firmó'; + if (signature.signerType === 'INSPECTOR') return 'Firmó como inspector/a'; + if (signature.companyManifestation === 'DISSENT') return `Firmó en disconformidad${signature.companyStatement ? ` · ${signature.companyStatement}` : ''}`; + return 'Firmó en conformidad'; } export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) { @@ -27,25 +20,11 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) { const [closure, setClosure] = useState(null); const [loading, setLoading] = useState(canRead); const [error, setError] = useState(''); - useEffect(() => { if (!canRead) return; setLoading(true); - getInspectionClosureF4(act.id) - .then(setClosure) - .catch((requestError) => setError(errorMessage(requestError))) - .finally(() => setLoading(false)); + getInspectionClosureF4(act.id).then(setClosure).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [act.id, canRead]); - - const inspectorSignatures = useMemo( - () => closure?.signatures.filter((item) => item.signerType === 'INSPECTOR') ?? [], - [closure], - ); - const companyOutcome = useMemo( - () => closure?.signatures.find((item) => item.signerType === 'COMPANY_RESPONSIBLE') ?? null, - [closure], - ); - const viewSignature = async (signature: InspectionActSignature) => { const tab = window.open('about:blank', '_blank'); if (tab) tab.opener = null; @@ -55,53 +34,19 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) { if (tab) tab.location.href = url; else window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); - } catch (requestError) { - tab?.close(); - setError(errorMessage(requestError)); - } + } catch (requestError) { tab?.close(); setError(errorMessage(requestError)); } }; - if (!canRead) return null; - if (loading || !closure) return
; - - const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome); - const isSealed = act.status === 'SEALED' || act.status === 'CLOSED'; - const isLocked = act.status === 'LOCKED' || act.status === 'READY'; - const lifecycleLabel = isSealed - ? 'Acta sellada' - : isLocked - ? 'Esperando manifestación' - : act.status === 'CANCELLED' - ? 'Acta cancelada' - : 'Borrador en campo'; - const lifecycleClass = isSealed ? 'active' : isLocked ? 'observed' : act.status === 'CANCELLED' ? 'inactive' : 'pending'; - - return
-
CIERRE DEL ACTA · SÓLO LECTURA

Responsable, firmas y sellado

Al bloquearse, el contenido del Acta queda inmutable. La Inspección puede continuar y generar otras Actas.

{lifecycleLabel}
- + if (loading || !closure) return
; + return
+

Participantes y firmas

{error && {error}} -

El dashboard no modifica el Acta. El bloqueo y la firma del Inspector se realizan desde la APK. La manifestación de la empresa puede completarse en campo o mediante el enlace seguro posterior.

- -
-
1Responsable{closure.responsible ? 'Registrado' : 'Pendiente'}
-
2Bloqueo{closure.closure?.isCurrent ? 'Contenido inmutable' : 'Pendiente'}
-
3Manifestaciones{inspectorSignatures.length} inspector · {companyOutcome ? 'empresa resuelta' : 'empresa pendiente'}
-
4Sellado{isSealed ? 'Definitivo' : 'Pendiente'}
-
- -
-
Urgencia{closure.act.urgency === 'URGENT' ? 'Urgente' : closure.act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}
-
Plazo configurado{closure.act.deadlineDays ? `${closure.act.deadlineDays} días ${closure.act.deadlineDayType === 'BUSINESS' ? 'hábiles' : 'corridos'}` : 'Pendiente'}
-
Inicio del plazo{closure.act.deadlineBaseAt ? formatDate(closure.act.deadlineBaseAt) : 'Pendiente de evento válido'}
-
Vencimiento{closure.act.deadlineAt ? formatDate(closure.act.deadlineAt) : 'Todavía no iniciado'}
-
- - {closure.responsible &&
Situación{closure.responsible.attendanceStatus === 'PRESENT' ? 'Presente' : 'Ausente'}
Responsable{closure.responsible.fullName ?? 'No estuvo presente'}
Documento / cargo{closure.responsible.documentNumber ? `${closure.responsible.documentType} ${closure.responsible.documentNumber}` : 'No informado'}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}
Contacto{closure.responsible.email ?? closure.responsible.phone ?? 'No informado'}
} - - {closure.closure?.isCurrent &&
CONTENIDO BLOQUEADO{closure.closure.schemaVersion}Bloqueado {formatDate(closure.act.lockedAt ?? closure.closure.preparedAt)}
{closure.act.lockedSha256 ?? closure.closure.preparedSha256}
} - - {closure.signatures.length > 0 &&
{closure.signatures.map((signature) =>
{signatureStatusLabel(signature.status)}{signature.signerName}{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}
{signature.signaturePayloadSha256}{signature.status === 'SIGNED' ? <>{signature.signerType === 'COMPANY_RESPONSIBLE' &&

{signature.companyManifestation === 'DISSENT' ? 'Firma en disidencia' : 'Firma en conformidad'}{signature.companyStatement ? ` · ${signature.companyStatement}` : ''}

} :

{signature.reason}

}
)}
} - - {isSealed && closure.closure?.finalSha256 &&
ACTA SELLADA{formatDate(closure.act.sealedAt ?? closure.closure.serverClosedAt)}

El Acta quedó sellada e inmutable. La Inspección y sus demás Actas continúan con ciclo independiente.

{closure.closure.finalSha256}
} + {closure.responsible &&

Representante de la empresa: {closure.responsible.fullName ?? 'No estuvo presente'}{closure.responsible.documentNumber ? ` · ${closure.responsible.documentType} ${closure.responsible.documentNumber}` : ''}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}

} + {closure.signatures.length ?
{closure.signatures.map((signature) =>
+
{signature.signerName}{signature.signerType === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa'} · {formatDate(signature.signedAt ?? signature.createdAt)}

{signatureOutcome(signature)}

+ {signature.status === 'SIGNED' && } +
)}
:

Las firmas todavía no se registraron.

} + {(act.status === 'SEALED' || act.status === 'CLOSED') &&

Acta firmada y cerrada {formatDate(closure.act.sealedAt ?? closure.act.closedAt)}

} + {closure.closure?.finalSha256 &&
Verificar integridad del Acta

SHA-256 del cierre: {closure.closure.finalSha256}

}
; } diff --git a/web-v2/src/features/inspections/inspectionActPresentation.ts b/web-v2/src/features/inspections/inspectionActPresentation.ts index 21d9f5a..70a5240 100644 --- a/web-v2/src/features/inspections/inspectionActPresentation.ts +++ b/web-v2/src/features/inspections/inspectionActPresentation.ts @@ -1,11 +1,11 @@ import type { InspectionActVersionEvent } from '../../lib/api'; const statusLabels: Record = { - DRAFT: 'Borrador', - LOCKED: 'Bloqueada · esperando manifestación', - SEALED: 'Sellada', + DRAFT: 'En elaboración', + LOCKED: 'Para firmar', + SEALED: 'Firmada y cerrada', READY: 'Lista para cerrar · legado', - CLOSED: 'Cerrada · legado', + CLOSED: 'Firmada y cerrada', CANCELLED: 'Cancelada', RECTIFIED: 'Rectificada · legado', }; diff --git a/web-v2/src/layout/AppLayout.tsx b/web-v2/src/layout/AppLayout.tsx index 17876da..1bbd1c2 100644 --- a/web-v2/src/layout/AppLayout.tsx +++ b/web-v2/src/layout/AppLayout.tsx @@ -18,13 +18,12 @@ const operational: NavItem[] = [ ]; const followUp: NavItem[] = [ - { to: '/seguimiento-actas', label: 'Seguimiento de actas', icon: 'clipboard', permission: 'inspection_acts.read' }, { to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' }, ]; const documents: NavItem[] = [ { to: '/actas', label: 'Actas', icon: 'clipboard', permission: 'inspection_acts.read' }, - { to: '/informes', label: 'Informes', icon: 'audit', permission: 'inspection_reports.read' }, + { to: '/informes', label: 'Informes y respuestas', icon: 'audit', permission: 'inspection_reports.read' }, ]; const master: NavItem[] = [ diff --git a/web-v2/src/lib/api.ts b/web-v2/src/lib/api.ts index 569318e..6483d10 100644 --- a/web-v2/src/lib/api.ts +++ b/web-v2/src/lib/api.ts @@ -2707,6 +2707,10 @@ export function getInspectionAct(id: string) { return apiRequest(`/inspection-acts/${id}`); } +export function getInspectionActConsolidatedPdfBlob(actId: string) { + return apiBlobRequest(`/inspection-acts/${actId}/consolidated-pdf`); +} + export function getInspectionActPdfBlob(actId: string) { return apiBlobRequest(`/inspection-acts/${actId}/pdf`); } diff --git a/web-v2/src/lib/reportWorkflowApi.ts b/web-v2/src/lib/reportWorkflowApi.ts index e73d361..434189d 100644 --- a/web-v2/src/lib/reportWorkflowApi.ts +++ b/web-v2/src/lib/reportWorkflowApi.ts @@ -205,3 +205,11 @@ export function inspectionReportWordDownloadUrl(id: string) { export function inspectionReportGedoPdfDownloadUrl(id: string) { return `/api/v3/inspection-reports/${id}/gedo-pdf`; } + +export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) { + return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`; +} + +export function inspectionReportConsolidatedWordDownloadUrl(reportId: string) { + return `/api/v3/inspection-reports/${reportId}/consolidated-word`; +} diff --git a/web-v2/src/pages/ActAdministrationDetailPage.tsx b/web-v2/src/pages/ActAdministrationDetailPage.tsx index 95478f1..f9fc17b 100644 --- a/web-v2/src/pages/ActAdministrationDetailPage.tsx +++ b/web-v2/src/pages/ActAdministrationDetailPage.tsx @@ -1,20 +1,31 @@ - import { useEffect, useState } from 'react'; -import type { FormEvent } from 'react'; import { Link, useParams } from 'react-router'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; -import { addActCompanyResponse, actCompanyResponseContentUrl, getActAdministration, setActResponseDeadline } from '../lib/api'; +import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api'; import type { ActAdministrationDetail } from '../lib/api'; +import { getInspectionActF4 } from '../lib/inspectionActF4Api'; import { formatDate } from '../lib/format'; -export function ActAdministrationDetailPage(){ - const {actId=''}=useParams(); const [data,setData]=useState(null); const [error,setError]=useState(''); const [busy,setBusy]=useState(false); const [due,setDue]=useState(''); const [reason,setReason]=useState('Plazo administrativo otorgado a la empresa'); const [received,setReceived]=useState(new Date().toISOString().slice(0,10)); const [details,setDetails]=useState(''); const [commitment,setCommitment]=useState(''); const [file,setFile]=useState(); - const load=()=>getActAdministration(actId).then(setData).catch(e=>setError(errorMessage(e))); useEffect(()=>{void load()},[actId]); - const deadline=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await setActResponseDeadline(actId,{responseDueOn:due,reason});setDue('');await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}}; - const response=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await addActCompanyResponse(actId,{receivedOn:received,details:details||undefined,committedCorrectionOn:commitment||undefined,file});setDetails('');setCommitment('');setFile(undefined);await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}}; - if(!data&&!error)return ; - return
{error&&{error}}{data&&<>
EXPEDIENTE ADMINISTRATIVO

{data.act.actCode}

{data.act.areaName??'Área sin asignar'} · {data.act.companyName??'Empresa sin asignar'} · {data.act.openFindingCount} hallazgos abiertos

Ver Acta
-

Plazo de respuesta

El plazo aplica al conjunto completo de hallazgos del Acta.