feat(f6.9): consolidate act and report documents and simplify follow-up
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m24s
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m22s

This commit is contained in:
DH V2
2026-09-15 18:48:12 -03:00
parent 079728aa6d
commit 5cf99442a8
39 changed files with 1604 additions and 566 deletions
+2
View File
@@ -4,6 +4,7 @@ COPY package*.json ./
RUN npm ci RUN npm ci
COPY nest-cli.json tsconfig.json ./ COPY nest-cli.json tsconfig.json ./
COPY src ./src COPY src ./src
COPY assets ./assets
RUN npm run build RUN npm run build
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
@@ -13,6 +14,7 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist 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 RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
USER node USER node
EXPOSE 3000 EXPOSE 3000
Binary file not shown.
Binary file not shown.
+78
View File
@@ -0,0 +1,78 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: DejaVu fonts
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (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 <pce@users.sourceforge.net>
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
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'.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+742 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-8", "version": "0.29.0-9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-8", "version": "0.29.0-9",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
@@ -21,9 +21,11 @@
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"pdfkit": "^0.20.2",
"pg": "^8.0.0", "pg": "^8.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sharp": "^0.35.4",
"typeorm": "^0.3.0" "typeorm": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
@@ -32,6 +34,7 @@
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pdfkit": "^0.17.6",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.0" "typescript": "^5.9.0"
@@ -216,6 +219,16 @@
"node": ">=12" "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": { "node_modules/@epic-web/invariant": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
@@ -664,6 +677,506 @@
"node": ">=18" "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": { "node_modules/@inquirer/ansi": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
@@ -1459,6 +1972,30 @@
"typeorm": "^0.3.0 || ^1.0.0-dev" "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": { "node_modules/@phc/format": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", "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==", "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
"license": "MIT" "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": { "node_modules/@tokenizer/inflate": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@@ -1659,6 +2205,16 @@
"undici-types": "~7.18.0" "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": { "node_modules/@types/qs": {
"version": "6.15.1", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -2185,6 +2741,15 @@
"concat-map": "0.0.1" "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": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -2805,6 +3370,21 @@
"node": ">= 0.8" "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": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -3156,7 +3736,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
@@ -3189,6 +3768,12 @@
], ],
"license": "BSD-3-Clause" "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": { "node_modules/file-type": {
"version": "21.3.4", "version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
@@ -3228,6 +3813,32 @@
"url": "https://opencollective.com/express" "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": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -3906,6 +4517,25 @@
"integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==", "integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==",
"license": "MIT" "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": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "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==", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4475,6 +5111,20 @@
"node": ">=8" "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": { "node_modules/pg": {
"version": "8.23.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
@@ -4594,6 +5244,14 @@
"node": ">=4" "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": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -4793,6 +5451,12 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -4997,6 +5661,55 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5437,6 +6150,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/to-buffer": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
@@ -5887,6 +6606,26 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT" "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": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-8", "version": "0.29.0-9",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -28,9 +28,11 @@
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"pdfkit": "^0.20.2",
"pg": "^8.0.0", "pg": "^8.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sharp": "^0.35.4",
"typeorm": "^0.3.0" "typeorm": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
@@ -39,6 +41,7 @@
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pdfkit": "^0.17.6",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.0" "typescript": "^5.9.0"
@@ -1,14 +1,9 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import { Controller, Get, Param, ParseUUIDPipe, Query, Res } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { ActAdministrationService } from './act-administration.service';
import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service';
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto'; import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
@Controller('act-administration') @Controller('act-administration')
export class ActAdministrationQueueController { export class ActAdministrationQueueController {
@@ -21,11 +16,8 @@ export class ActAdministrationQueueController {
export class ActAdministrationController { export class ActAdministrationController {
constructor(private readonly administration: ActAdministrationService) {} constructor(private readonly administration: ActAdministrationService) {}
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); } @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') // Historic deadlines and responses remain readable here.
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); } // New administrative entries are recorded on the related Informe.
@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); }
} }
@Controller('act-company-responses') @Controller('act-company-responses')
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F69ConsolidatedActDocument1790131800000 implements MigrationInterface {
name = 'F69ConsolidatedActDocument1790131800000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query('DROP TABLE inspection_report_consolidated_word_artifacts');
await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_artifacts');
}
}
@@ -629,6 +629,13 @@ export class InspectionFindingsService {
if (Object.keys(dto).length === 0) { if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); 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) => { return this.dataSource.transaction(async (manager) => {
const finding = await this.lockFinding(manager, id); const finding = await this.lockFinding(manager, id);
this.assertFindingOpen(finding); this.assertFindingOpen(finding);
@@ -1,214 +1,131 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import PDFDocument from 'pdfkit';
function asRecord(value: unknown): Record<string, unknown> { export interface ActPdfImage {
return value && typeof value === 'object' && !Array.isArray(value) id: string;
? value as Record<string, unknown> findingId?: string;
: {}; assetId?: string;
signerName?: string;
title?: string;
capturedAt?: string | Date | null;
sha256: string;
buffer: Buffer;
} }
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function asArray(value: unknown): Array<Record<string, unknown>> { function asArray(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.map(asRecord) : []; return Array.isArray(value) ? value.map(asRecord) : [];
} }
function text(value: unknown, fallback = ''): string {
function text(value: unknown, fallback = '-'): string { return String(value ?? '').trim() || fallback;
const out = String(value ?? '').trim();
return out || fallback;
} }
function date(value: unknown): string { function date(value: unknown): string {
const parsed = new Date(String(value ?? '')); 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 { // %PDF-1.4 is the document-version contract for consolidated Actas.
return value export async function buildInspectionActPdf(snapshot: Record<string, unknown>, images: ActPdfImage[] = [], context: { companyName?: string | null; areaName?: string | null; scopeName?: string | null } = {}): Promise<{ buffer: Buffer; sha256: string }> {
.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<string, unknown>): {
locked: Record<string, unknown>;
signatures: Array<Record<string, unknown>>;
finalSha256: unknown;
} {
const sealed = asRecord(snapshot); const sealed = asRecord(snapshot);
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot); 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, unknown>): string[] {
const source = lockedSnapshot(snapshot);
const locked = source.locked;
const act = asRecord(locked.act); 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 responsible = asRecord(locked.responsible);
const inventories = asArray(locked.inventories ?? locked.assets); const inventories = asArray(locked.inventories ?? locked.assets);
const findings = asArray(locked.findings); const findings = asArray(locked.findings);
const signatures = source.signatures; const signatures = asArray(sealed.signatures);
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE'); const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR'); 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<Buffer>((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 doc.font('body-bold').fillColor('#202939').fontSize(19).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})` doc.moveDown(0.5);
: act.deadlineBasis === 'GEDO_DATE' label('Inspección', inspection.code);
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO` label('Empresa inspeccionada', context.companyName);
: '-'; label('Área', context.areaName);
label('Yacimiento o instalación', context.scopeName);
const out: string[] = [ label('Fecha y hora', date(act.occurredAt));
'ACTA DE INSPECCION', label('Urgencia del Acta', text(act.urgency) === 'URGENT' ? 'Urgente' : text(act.urgency) === 'NON_URGENT' ? 'No urgente' : '');
'', label('Representante de la empresa', responsible.fullName);
`Acta: ${text(act.code)}`, label('DNI', responsible.documentNumber);
`Inspeccion: ${text(inspection.code)}`, label('Cargo o función', responsible.position);
`Fecha: ${date(act.occurredAt)}`, heading('Lo actuado');
`Urgencia: ${urgencyLabel(act.urgency)}`, if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary);
`Plazo: ${deadlineText}`, else body(`Se realizó la inspección ${text(inspection.code)}. El contenido constatado se detalla en los hallazgos registrados a continuación.`);
`Representante de la empresa: ${text(responsible.fullName)}`, if (act.observations) { label('Observaciones', act.observations); }
`DNI: ${text(responsible.documentNumber)}`, if (inventories.length) {
`Cargo / funcion: ${text(responsible.position)}`, heading('Instalaciones inspeccionadas');
`Email: ${text(responsible.email)}`, for (const item of inventories) body(`${text(item.name)} (${text(item.code)}) · ${text(item.typeName ?? item.typeCode)}`);
'',
'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)}`));
} }
heading('Hallazgos y fotografías');
out.push('', 'HALLAZGOS'); if (!findings.length) body('No se registraron hallazgos.');
if (!findings.length) out.push('Sin hallazgos registrados.'); const shownAssetPhotos = new Set<string>();
for (const item of findings) { for (const finding of findings) {
const recurrence = item.isRecurrence need(75);
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}` doc.font('body-bold').fillColor(blue).fontSize(11).text(`${text(finding.code)} · ${text(finding.title)}`);
: ''; label('Descripción', finding.description);
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`)); if (finding.legalBasis) label('Normativa consignada', finding.legalBasis);
out.push(...wrap(`Descripcion: ${text(item.description)}`)); if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`);
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`)); 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 (!companySignature) { if (!findings.length) for (const photo of images.filter((item) => item.assetId)) image(photo, `Fotografía de inventario ${text(photo.title)}`);
out.push('Manifestacion de empresa: pendiente.'); heading('Intervinientes y firmas');
} else if (text(companySignature.status, '') === 'SIGNED') { for (const signature of signatures) {
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY'); const name = text(signature.signerName);
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disconformidad' : 'Empresa: firma en conformidad'); const role = text(signature.signerType) === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa';
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement))); const status = text(signature.status);
} else { label(role, `${name} · ${status === 'SIGNED' ? 'Firmó' : status === 'REFUSED' ? 'Se negó a firmar' : 'No firmó'} · ${date(signature.signedAt ?? signature.createdAt)}`);
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`)); 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; }
} }
heading('Integridad del Acta');
out.push( body(`SHA-256 del cierre: ${hash}`);
'', body(`SHA-256 del contenido cerrado: ${text(sealed.lockedSha256)}`);
'INTEGRIDAD', need(25);
`Hash del Acta sellada: ${text(source.finalSha256)}`, doc.fontSize(8).fillColor('#637088').text(`Acta ${text(act.code)} · documento consolidado · ${date(asRecord(sealed.seal).serverSealedAt)}`, 54, doc.y);
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`, doc.end();
); const buffer = await done;
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<string, unknown>): { buffer: Buffer; sha256: string } {
const all = lines(snapshot);
const chunks: Array<string[]> = [];
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')]);
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
} }
@@ -4,11 +4,15 @@ import { isAbsolute, parse, resolve } from 'node:path';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; 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() @Injectable()
export class InspectionActPdfService { export class InspectionActPdfService {
private readonly root: string; private readonly root: string;
private readonly evidenceRoot: string;
private readonly assetRoot: string;
private readonly signatureRoot: string;
constructor(private readonly dataSource: DataSource, config: ConfigService) { constructor(private readonly dataSource: DataSource, config: ConfigService) {
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT') const configured = config.get<string>('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'); if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
this.root = resolve(configured); this.root = resolve(configured);
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
this.evidenceRoot = resolve(config.get<string>('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings');
this.assetRoot = resolve(config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media');
this.signatureRoot = resolve(config.get<string>('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures');
} }
async ensure(actId: string): Promise<void> { async ensure(actId: string): Promise<void> {
@@ -59,7 +66,7 @@ export class InspectionActPdfService {
`, [actId]); `, [actId]);
try { try {
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; 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 }); await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${actId}.pdf`; const storedName = `${actId}.pdf`;
const originalName = `${row.code}.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<string, unknown> }>;
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<Buffer> {
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<ActPdfImage[]> {
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 }> { async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT original_name AS "originalName",stored_name AS "storedName", SELECT original_name AS "originalName",stored_name AS "storedName",
@@ -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<Buffer> {
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();
}
@@ -1,4 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
interface ZipEntry { interface ZipEntry {
name: string; name: string;
@@ -13,6 +15,10 @@ interface ReportWordInput {
frozenSnapshot: Record<string, unknown>; frozenSnapshot: Record<string, unknown>;
executiveSummary?: string | null; executiveSummary?: string | null;
reportDescription?: 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 = (() => { const crcTable = (() => {
@@ -68,7 +74,7 @@ function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): st
} }
function labelValue(label: string, value: string): string { function labelValue(label: string, value: string): string {
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`; return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
} }
function table(headers: string[], rows: string[][]): string { function table(headers: string[], rows: string[][]): string {
@@ -102,75 +108,80 @@ function deadline(act: Record<string, unknown>): string {
return '—'; 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 `<w:p>${right ? '<w:pPr><w:jc w:val="right"/></w:pPr>' : ''}<w:r><w:drawing><wp:inline><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${relationship}" name="${alt}" descr="${alt}"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="${alt}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rId${relationship}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>`;
}
function documentXml(input: ReportWordInput, logo: Buffer): string {
const snapshot = f4Snapshot(input); const snapshot = f4Snapshot(input);
const findingRows = snapshot.findings.map((item) => [ const photos = input.photos ?? [];
text(item.code), const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', ');
text(item.title), const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))];
text(item.description), const entries: string[] = [
item.isRecurrence ? `${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No', paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'),
item.severity == null ? '—' : `${text(item.severity)}/10`, paragraph('DIRECCIÓN DE HIDROCARBUROS'),
]); drawing(2, logo, 'Identidad institucional Mendoza', 594360, 1005840, true),
const inventoryRows = snapshot.inventories.map((item) => [ paragraph(`Mendoza, ${input.generatedAt.toLocaleDateString('es-AR')}`),
text(item.code), paragraph(`INFORME TÉCNICO ${input.code}`, 'Title'),
text(item.name), paragraph('Sr. Director de Hidrocarburos'),
text(item.typeName ?? item.typeCode), labelValue('Referencia', text(input.scopeName ?? snapshot.inventories[0]?.name, 'Instalación inspeccionada')),
]); labelValue('Inspector/a', authors || 'No consignado'),
const signatureRows = snapshot.signatures.map((item) => [ labelValue('Acta', text(snapshot.source.actCode ?? snapshot.act.code)),
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)),
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)), labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)), labelValue('Área o Yacimiento', text(input.scopeName ?? input.areaName)),
labelValue('Urgencia', urgency(snapshot.act.urgency)), labelValue('Empresa inspeccionada', text(input.companyName)),
labelValue('Plazo', deadline(snapshot.act)), paragraph('OBJETIVOS', 'Heading1'),
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')), labelValue('General', 'Documentar los resultados de la inspección consignados en el Acta fuente.'),
paragraph('Resumen ejecutivo', 'Heading1'), labelValue('Particular', 'Analizar los hallazgos y el estado de las instalaciones inspeccionadas para definir las acciones y verificaciones que correspondan.'),
paragraph(executive), paragraph('MARCO LEGAL', 'Heading1'),
paragraph('Descripción / análisis técnico', 'Heading1'), ...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]),
paragraph(description), paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'),
paragraph('Acta fuente · contenido inmutable', '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('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'), paragraph('FOTOS Y HALLAZGOS', 'Heading1'),
labelValue('Resumen del Acta', text(snapshot.act.summary)), ];
labelValue('Observaciones del Acta', text(snapshot.act.observations)), if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.'));
labelValue('Representante de la empresa', text(snapshot.responsible.fullName)), for (const finding of snapshot.findings) {
labelValue('DNI', text(snapshot.responsible.documentNumber)), entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2'));
labelValue('Cargo / función', text(snapshot.responsible.position)), entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name)));
labelValue('Email', text(snapshot.responsible.email)), entries.push(paragraph(text(finding.description)));
paragraph('Inventario inspeccionado', 'Heading1'), if (finding.legalBasis) entries.push(labelValue('Normativa', text(finding.legalBasis)));
inventoryRows.length if (finding.severity != null) entries.push(labelValue('Gravedad', `${text(finding.severity)}/10`));
? table(['Código', 'Nombre', 'Tipo'], inventoryRows) for (let index = 0; index < photos.length; index++) {
: paragraph('No se registraron elementos de Inventario en el Acta.'), const photo = photos[index]!;
paragraph('Hallazgos', 'Heading1'), if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue;
findingRows.length entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title)));
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows) entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`));
: paragraph('El Acta no contiene Hallazgos.'), }
paragraph('Firmas y manifestaciones', 'Heading1'), }
signatureRows.length entries.push(
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows) paragraph('CONCLUSIONES', 'Heading1'),
: paragraph('No se registraron firmas en la instantánea sellada.'), 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('Integridad de la fuente', 'Heading1'), paragraph('ACTA FUENTE E INTEGRIDAD', 'Heading1'),
labelValue('Hash de la fuente del INF', input.frozenSha256), labelValue('Acta sellada', text(snapshot.source.actCode ?? snapshot.act.code)),
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)), labelValue('SHA-256 del cierre del Acta', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)), labelValue('SHA-256 de la fuente del Informe', input.frozenSha256),
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(''); const body = entries.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
} }
function buildZip(entries: ZipEntry[]): Buffer { function buildZip(entries: ZipEntry[]): Buffer {
@@ -232,10 +243,13 @@ function buildZip(entries: ZipEntry[]): Buffer {
} }
export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } { 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) => `<Relationship Id="rId${index + 3}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}"/>`).join('');
const entries: ZipEntry[] = [ const entries: ZipEntry[] = [
{ {
name: '[Content_Types].xml', name: '[Content_Types].xml',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'), data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="jpg" ContentType="image/jpeg"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
}, },
{ {
name: '_rels/.rels', name: '_rels/.rels',
@@ -243,13 +257,15 @@ export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buf
}, },
{ {
name: 'word/_rels/document.xml.rels', name: 'word/_rels/document.xml.rels',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>', 'utf8'), data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/logo-mendoza.png"/>${mediaRelationships}</Relationships>`, 'utf8'),
}, },
{ {
name: 'word/styles.xml', name: 'word/styles.xml',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style></w:styles>', 'utf8'), data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr><w:pPr><w:spacing w:before="160" w:after="180"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="26"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="260" w:after="130"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="180" w:after="100"/></w:pPr></w:style></w:styles>', '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', name: 'docProps/core.xml',
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'), data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'),
@@ -5,11 +5,16 @@ import { Injectable, InternalServerErrorException, NotFoundException } from '@ne
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { buildInspectionReportWord } from './inspection-report-word-builder'; import { buildInspectionReportWord } from './inspection-report-word-builder';
import { InspectionActPdfService } from './inspection-act-pdf.service';
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
interface WordRow { interface WordRow {
id: string; id: string;
actId: string;
companyName: string | null;
areaName: string | null;
scopeName: string | null;
code: string; code: string;
title: string; title: string;
executiveSummary: string | null; executiveSummary: string | null;
@@ -30,7 +35,7 @@ interface WordRow {
export class InspectionReportWordService { export class InspectionReportWordService {
private readonly root: string; 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<string>('INSPECTION_REPORT_WORD_ROOT') const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
?? '/app/storage/asset-media/inspection-reports-word'; ?? '/app/storage/asset-media/inspection-reports-word';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path'); if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
@@ -48,6 +53,7 @@ export class InspectionReportWordService {
} catch {} } catch {}
} }
try { try {
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
const built = buildInspectionReportWord({ const built = buildInspectionReportWord({
code: row.code, code: row.code,
title: row.title, title: row.title,
@@ -56,6 +62,10 @@ export class InspectionReportWordService {
frozenSnapshot: row.frozenSnapshot, frozenSnapshot: row.frozenSnapshot,
executiveSummary: row.executiveSummary, executiveSummary: row.executiveSummary,
reportDescription: row.reportDescription, reportDescription: row.reportDescription,
companyName: row.companyName,
areaName: row.areaName,
scopeName: row.scopeName,
photos,
}); });
await mkdir(this.root, { recursive: true, mode: 0o700 }); await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`; 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<void> { private async ensureInitialRevision(row: WordRow): Promise<void> {
if ( if (
row.wordStatus !== 'READY' row.wordStatus !== 'READY'
@@ -150,21 +210,26 @@ export class InspectionReportWordService {
private async load(reportId: string): Promise<WordRow> { private async load(reportId: string): Promise<WordRow> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT id,code,title, SELECT report.id,report.act_id AS "actId",report.code,report.title,
executive_summary AS "executiveSummary", company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName",
report_description AS "reportDescription", report.executive_summary AS "executiveSummary",
generated_at AS "generatedAt", report.report_description AS "reportDescription",
frozen_sha256 AS "frozenSha256", report.generated_at AS "generatedAt",
frozen_snapshot AS "frozenSnapshot", report.frozen_sha256 AS "frozenSha256",
word_status AS "wordStatus", report.frozen_snapshot AS "frozenSnapshot",
word_original_name AS "wordOriginalName", report.word_status AS "wordStatus",
word_stored_name AS "wordStoredName", report.word_original_name AS "wordOriginalName",
word_mime_type AS "wordMimeType", report.word_stored_name AS "wordStoredName",
report.word_mime_type AS "wordMimeType",
word_size_bytes::integer AS "wordSizeBytes", word_size_bytes::integer AS "wordSizeBytes",
word_sha256 AS "wordSha256", report.word_sha256 AS "wordSha256",
generated_by AS "generatedBy" report.generated_by AS "generatedBy"
FROM inspection_reports FROM inspection_reports report
WHERE id=$1 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[]; `, [reportId]) as WordRow[];
if (!row) { if (!row) {
throw new NotFoundException({ throw new NotFoundException({
@@ -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( async addFollowUp(
reportId: string, reportId: string,
dto: CreateInspectionReportFollowUpDto, dto: CreateInspectionReportFollowUpDto,
@@ -69,6 +69,20 @@ export class InspectionReportsController {
return response.sendFile(content.filePath); 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') @Get(':id/gedo-pdf')
@RequirePermissions('inspection_reports.read') @RequirePermissions('inspection_reports.read')
async gedoPdfContent( async gedoPdfContent(
@@ -113,6 +127,23 @@ export class InspectionReportsController {
return this.workflow.listFollowUps(id); 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<void> {
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<void>((resolveSend, rejectSend) => response.sendFile(file.filePath, (error) => error ? rejectSend(error) : resolveSend()));
}
@Post(':id/follow-ups') @Post(':id/follow-ups')
@RequirePermissions('inspection_reports.generate') @RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', { @UseInterceptors(FileInterceptor('file', {
@@ -170,3 +201,23 @@ export class InspectionActPdfController {
return response.send(content.buffer); 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);
}
}
@@ -7,7 +7,7 @@ import { InspectionDeadlineAdminService } from './inspection-deadline-admin.serv
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import { InspectionReportWorkflowService } from './inspection-report-workflow.service'; import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
import { InspectionReportWordService } from './inspection-report-word.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 { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service'; import { SmtpDeliveryService } from './smtp-delivery.service';
@@ -17,6 +17,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
InspectionReportsController, InspectionReportsController,
InspectionActReportController, InspectionActReportController,
InspectionActPdfController, InspectionActPdfController,
InspectionActConsolidatedPdfController,
InspectionDeadlineAdminController, InspectionDeadlineAdminController,
DocumentDeliveryController, DocumentDeliveryController,
], ],
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-8'; export const API_VERSION = '0.29.0-9';
export const API_PHASE = 'F6.8'; export const API_PHASE = 'F6.9';
@@ -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', () => { test('F4 closure UI treats sealing as the definitive act state', () => {
assert.match(closurePanel, /getInspectionClosureF4/); assert.match(closurePanel, /getInspectionClosureF4/);
assert.match(closurePanel, /act\.status === 'SEALED'/); assert.match(closurePanel, /act\.status === 'SEALED'/);
assert.match(closurePanel, /ACTA SELLADA/); assert.match(closurePanel, /Participantes y firmas/);
assert.match(closurePanel, /manifestación de la empresa puede completarse/); assert.doesNotMatch(closurePanel, /Pendiente de evento válido/);
assert.match(closurePanel, /Pendiente de evento válido/);
assert.doesNotMatch(closurePanel, /reabrir ni cerrar el acta/); assert.doesNotMatch(closurePanel, /reabrir ni cerrar el acta/);
}); });
test('F4 presentation keeps new lifecycle labels while retaining explicit legacy readability', () => { test('F4 presentation keeps new lifecycle labels while retaining explicit legacy readability', () => {
assert.match(presentation, /LOCKED: 'Bloqueada/); assert.match(presentation, /LOCKED: 'Para firmar/);
assert.match(presentation, /SEALED: 'Sellada'/); assert.match(presentation, /SEALED: 'Firmada y cerrada'/);
assert.match(presentation, /READY: 'Lista para cerrar · legado'/); assert.match(presentation, /READY: 'Lista para cerrar · legado'/);
assert.match(presentation, /CLOSED: 'Cerrada · legado'/); assert.match(presentation, /CLOSED: 'Firmada y cerrada'/);
}); });
+3 -3
View File
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.8 release', () => { test('health metadata reports the current F6.9 release', () => {
assert.equal(API_PHASE, 'F6.8'); assert.equal(API_PHASE, 'F6.9');
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
assert.equal(API_VERSION, pkg.version); assert.equal(API_VERSION, pkg.version);
assert.equal(API_VERSION, '0.29.0-8'); assert.equal(API_VERSION, '0.29.0-9');
}); });
@@ -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]; const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version); 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', () => { test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
@@ -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, /Firmas y manifestaciones/);
assert.match(projection, /GEDO e integridad/); assert.match(projection, /GEDO e integridad/);
assert.match(projection, /Anexo final/); assert.match(projection, /Anexo final/);
assert.match(actPage, /<DocumentPdfProjection kind="act" \/>/); assert.match(actPage, /getInspectionActConsolidatedPdfBlob/);
assert.match(reportPage, /<DocumentPdfProjection kind="report" \/>/); assert.doesNotMatch(actPage, /DocumentPdfProjection/);
assert.doesNotMatch(reportPage, /DocumentPdfProjection/);
}); });
@@ -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(api('src/inspection-acts/inspection-acts.service.ts'), /capture\.visit_id=act\.visit_id/);
assert.match(page, /Abrir PDF del Acta/); assert.match(page, /Abrir PDF del Acta/);
assert.match(page, /Descargar PDF/); assert.match(page, /Descargar PDF/);
assert.match(media, /Fotos y Hallazgos/); assert.match(media, /Hallazgos del Acta/);
assert.match(media, /Evidencia fotográfica/); assert.match(media, /act-finding-photos/);
assert.match(media, /Fotos tomadas durante esta inspección/); assert.match(media, /listInspectionFindingEvidence/);
}); });
test('F6.8 presents technical families to users as installation and subinstallation types', () => { test('F6.8 presents technical families to users as installation and subinstallation types', () => {
@@ -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)));
});
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-5", "version": "0.23.0-6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-5", "version": "0.23.0-6",
"dependencies": { "dependencies": {
"maplibre-gl": "6.4.1", "maplibre-gl": "6.4.1",
"react": "^19.0.0", "react": "^19.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-5", "version": "0.23.0-6",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-5'; export const APP_VERSION = '0.23.0-6';
export const APP_PHASE = 'F6.8 · Offline seguro y Acta documental'; export const APP_PHASE = 'F6.9 · Actas e informes consolidados';
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { import {
getAssetMediaBlob, getAssetMediaBlob,
getInspectionFindingEvidenceBlob, getInspectionFindingEvidenceBlob,
@@ -8,142 +7,70 @@ import {
listInspectionFindingEvidence, listInspectionFindingEvidence,
listInspectionFindings, listInspectionFindings,
} from '../../lib/api'; } from '../../lib/api';
import type { InspectionActFieldMedia, InspectionFindingEvidence } from '../../lib/api'; import type { InspectionActFieldMedia, InspectionFinding, InspectionFindingEvidence } from '../../lib/api';
import { formatDate } from '../../lib/format'; import { formatDate } from '../../lib/format';
type ActPhoto = { type FindingWithPhotos = { finding: InspectionFinding; photos: InspectionFindingEvidence[] };
findingId: string;
findingCode: string;
findingTitle: string;
evidence: InspectionFindingEvidence;
};
type AssetPhoto = InspectionActFieldMedia; function Photo({ id, title, caption, load }: { id: string; title: string; caption: string; load: (id: string) => Promise<Blob> }) {
function PhotoThumb({ photo }: { photo: ActPhoto }) {
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
useEffect(() => { useEffect(() => {
let active = true; let active = true;
let objectUrl = ''; let objectUrl = '';
getInspectionFindingEvidenceBlob(photo.evidence.id) load(id).then((blob) => {
.then((blob) => {
if (!active) return; if (!active) return;
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl); setUrl(objectUrl);
}) }).catch(() => undefined);
.catch(() => undefined); return () => { active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
return () => { }, [id, load]);
active = false; return <figure className="act-finding-photo">
if (objectUrl) URL.revokeObjectURL(objectUrl); {url ? <a href={url} target="_blank" rel="noopener noreferrer" aria-label={`Ver foto ${title}`}><img src={url} alt={title} /></a> : <span className="media-preview-loading">Cargando foto</span>}
}; <figcaption>{caption}</figcaption>
}, [photo.evidence.id]); </figure>;
return <article className="act-photo-card">
<button
type="button"
className="act-photo-preview"
onClick={() => url && window.open(url, '_blank', 'noopener,noreferrer')}
aria-label={`Ver foto ${photo.findingCode}`}
>
{url
? <img src={url} alt={photo.evidence.title || `${photo.findingCode} · ${photo.findingTitle}`} />
: <span className="media-preview-loading"><span className="spinner" /></span>}
</button>
<div className="act-photo-copy">
<span className="eyebrow">{photo.findingCode}</span>
<strong>{photo.findingTitle}</strong>
<small>{formatDate(photo.evidence.capturedAt || photo.evidence.createdAt)}</small>
{photo.evidence.latitude != null && photo.evidence.longitude != null && <small>
GPS {photo.evidence.latitude.toFixed(6)}, {photo.evidence.longitude.toFixed(6)}
</small>}
</div>
</article>;
} }
function AssetPhotoThumb({ photo }: { photo: AssetPhoto }) { function Finding({ item, assetPhotos }: { item: FindingWithPhotos; assetPhotos: InspectionActFieldMedia[] }) {
const [url, setUrl] = useState(''); const { finding, photos } = item;
return <article className="act-finding-record">
useEffect(() => { <div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.asset.name} · {finding.asset.code}</p></div></div>
let active = true; <p className="inspection-finding-description">{finding.description}</p>
let objectUrl = ''; {finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>}
getAssetMediaBlob(photo.id) {finding.severity != null && <p>Gravedad {finding.severity}/10</p>}
.then((blob) => { {(photos.length > 0 || assetPhotos.length > 0) && <div className="act-finding-photos">
if (!active) return; {photos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.title} caption={`Hallazgo · ${formatDate(photo.capturedAt || photo.createdAt)}${photo.latitude != null && photo.longitude != null ? ` · GPS ${photo.latitude.toFixed(6)}, ${photo.longitude.toFixed(6)}` : ''}`} load={getInspectionFindingEvidenceBlob} />)}
objectUrl = URL.createObjectURL(blob); {assetPhotos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.asset.name} caption={`Inventario · ${formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)}`} load={getAssetMediaBlob} />)}
setUrl(objectUrl); </div>}
}) {photos.length === 0 && assetPhotos.length === 0 && <small className="muted">Sin fotografías vinculadas.</small>}
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [photo.id]);
return <article className="act-photo-card">
<button type="button" className="act-photo-preview" onClick={() => url && window.open(url, '_blank', 'noopener,noreferrer')} aria-label={`Ver foto ${photo.assetCode}`}>
{url ? <img src={url} alt={photo.title || photo.assetName} /> : <span className="media-preview-loading"><span className="spinner" /></span>}
</button>
<div className="act-photo-copy">
<span className="eyebrow">INVENTARIO · {photo.assetCode}</span>
<strong>{photo.assetName}</strong>
<small>{formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)}</small>
{photo.latitude != null && photo.longitude != null && <small>GPS {photo.latitude.toFixed(6)}, {photo.longitude.toFixed(6)}</small>}
</div>
</article>; </article>;
} }
export function InspectionActMediaPanel({ actId }: { actId: string }) { export function InspectionActMediaPanel({ actId }: { actId: string }) {
const [photos, setPhotos] = useState<ActPhoto[]>([]); const [items, setItems] = useState<FindingWithPhotos[]>([]);
const [assetPhotos, setAssetPhotos] = useState<AssetPhoto[]>([]); const [assetPhotos, setAssetPhotos] = useState<InspectionActFieldMedia[]>([]);
const [findingCount, setFindingCount] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true); setLoading(true); setError('');
setError(''); Promise.all([listInspectionFindings(actId), listInspectionActFieldMedia(actId).catch(() => [])])
Promise.all([ .then(async ([findings, media]) => {
listInspectionFindings(actId), const records = await Promise.all(findings.map(async (finding) => ({
listInspectionActFieldMedia(actId).catch(() => []), finding, photos: (await listInspectionFindingEvidence(finding.id)).filter((evidence) => evidence.kind === 'PHOTO' && evidence.purpose === 'OBSERVATION'),
]) })));
.then(async ([findings, fieldMedia]) => {
const evidenceByFinding = await Promise.all(
findings.map(async (finding) => ({
finding,
evidence: await listInspectionFindingEvidence(finding.id),
})),
);
if (!active) return; if (!active) return;
setFindingCount(findings.length); setItems(records);
setPhotos(evidenceByFinding.flatMap(({ finding, evidence }) => setAssetPhotos(media.filter((photo) => photo.kind === 'PHOTO'));
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'));
}) })
.catch((requestError) => active && setError(errorMessage(requestError))) .catch((requestError) => active && setError(errorMessage(requestError)))
.finally(() => active && setLoading(false)); .finally(() => active && setLoading(false));
return () => { active = false; }; return () => { active = false; };
}, [actId]); }, [actId]);
return <section className="panel act-media-panel"> return <section className="panel act-media-panel">
<div className="panel-heading"> <div className="panel-heading"><div><h2>Hallazgos del Acta</h2><p className="section-copy">Cada hallazgo reúne su descripción y las fotos tomadas en campo.</p></div><span className="count-pill">{items.length}</span></div>
<div>
<span className="eyebrow">REGISTRO DE CAMPO</span>
<h2>Fotos y Hallazgos</h2>
<p className="section-copy">Las fotografías del Acta se muestran directamente, vinculadas al Hallazgo que documentan.</p>
</div>
<span className="count-pill">{photos.length + assetPhotos.length} foto{photos.length + assetPhotos.length === 1 ? '' : 's'} · {findingCount} hallazgo{findingCount === 1 ? '' : 's'}</span>
</div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando registro fotográfico…" /> : <> {loading ? <LoadingBlock label="Cargando hallazgos y fotografías…" /> : items.length ?
{photos.length === 0 && assetPhotos.length === 0 && <EmptyState title="Sin fotografías" text="Esta Acta todavía no tiene fotografías sincronizadas." />} <div className="act-finding-list">{items.map((item) => <Finding key={item.finding.id} item={item} assetPhotos={assetPhotos.filter((photo) => photo.assetId === item.finding.asset.id)} />)}</div> :
{photos.length > 0 && <div className="act-media-group"><div className="subsection-heading"><div><span className="eyebrow">HALLAZGOS</span><h4>Evidencia fotográfica</h4></div><span>{photos.length}</span></div><div className="act-photo-grid">{photos.map((photo) => <PhotoThumb key={photo.evidence.id} photo={photo} />)}</div></div>} <EmptyState title="Sin hallazgos" text="Esta Acta no contiene hallazgos sincronizados." />}
{assetPhotos.length > 0 && <div className="act-media-group"><div className="subsection-heading"><div><span className="eyebrow">INVENTARIO DE LA INSPECCIÓN</span><h4>Fotos tomadas durante esta inspección</h4></div><span>{assetPhotos.length}</span></div><div className="act-photo-grid">{assetPhotos.map((photo) => <AssetPhotoThumb key={photo.id} photo={photo} />)}</div></div>}
</>}
{(photos.length > 0 || assetPhotos.length > 0) && <div className="act-media-footnote"><Icon name="camera" /><span>Seleccioná una foto para verla a tamaño completo.</span></div>}
</section>; </section>;
} }
@@ -1,24 +1,17 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAuth } from '../../auth/AuthContext'; import { useAuth } from '../../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getInspectionSignatureBlob } from '../../lib/api'; import { getInspectionSignatureBlob } from '../../lib/api';
import type { InspectionActSignature } from '../../lib/api'; import type { InspectionActSignature } from '../../lib/api';
import { import { getInspectionClosureF4, type InspectionActF4, type InspectionClosureF4 } from '../../lib/inspectionActF4Api';
getInspectionClosureF4,
type InspectionActF4,
type InspectionClosureF4,
} from '../../lib/inspectionActF4Api';
import { formatDate } from '../../lib/format'; import { formatDate } from '../../lib/format';
function signatureStatusLabel(value: InspectionActSignature['status']): string { function signatureOutcome(signature: InspectionActSignature): string {
if (value === 'SIGNED') return 'Firmada'; if (signature.status === 'REFUSED') return `Se negó a firmar${signature.reason ? ` · ${signature.reason}` : ''}`;
if (value === 'REFUSED') return 'Se negó a firmar'; if (signature.status !== 'SIGNED') return 'No firmó';
return 'Ausente'; 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';
function signerTypeLabel(value: InspectionActSignature['signerType']): string {
return value === 'INSPECTOR' ? 'Inspector/a' : 'Responsable de la empresa';
} }
export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) { export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
@@ -27,25 +20,11 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
const [closure, setClosure] = useState<InspectionClosureF4 | null>(null); const [closure, setClosure] = useState<InspectionClosureF4 | null>(null);
const [loading, setLoading] = useState(canRead); const [loading, setLoading] = useState(canRead);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (!canRead) return; if (!canRead) return;
setLoading(true); setLoading(true);
getInspectionClosureF4(act.id) getInspectionClosureF4(act.id).then(setClosure).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
.then(setClosure)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [act.id, canRead]); }, [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 viewSignature = async (signature: InspectionActSignature) => {
const tab = window.open('about:blank', '_blank'); const tab = window.open('about:blank', '_blank');
if (tab) tab.opener = null; if (tab) tab.opener = null;
@@ -55,53 +34,19 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
if (tab) tab.location.href = url; if (tab) tab.location.href = url;
else window.open(url, '_blank', 'noopener,noreferrer'); else window.open(url, '_blank', 'noopener,noreferrer');
window.setTimeout(() => URL.revokeObjectURL(url), 60_000); window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (requestError) { } catch (requestError) { tab?.close(); setError(errorMessage(requestError)); }
tab?.close();
setError(errorMessage(requestError));
}
}; };
if (!canRead) return null; if (!canRead) return null;
if (loading || !closure) return <section className="panel inspection-closure-panel"><LoadingBlock label="Cargando cierre del acta…" /></section>; if (loading || !closure) return <section className="panel"><LoadingBlock label="Cargando firmas…" /></section>;
return <section className="panel act-signatures-panel">
const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome); <div className="panel-heading"><div><h2>Participantes y firmas</h2></div></div>
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 <section className="panel inspection-closure-panel">
<div className="panel-heading"><div><span className="eyebrow">CIERRE DEL ACTA · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">Al bloquearse, el contenido del Acta queda inmutable. La Inspección puede continuar y generar otras Actas.</p></div><span className={`status-badge large ${lifecycleClass}`}>{lifecycleLabel}</span></div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>El dashboard no modifica el Acta.</strong> 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.</p></div> {closure.responsible && <p><strong>Representante de la empresa:</strong> {closure.responsible.fullName ?? 'No estuvo presente'}{closure.responsible.documentNumber ? ` · ${closure.responsible.documentType} ${closure.responsible.documentNumber}` : ''}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</p>}
{closure.signatures.length ? <div className="act-signer-list">{closure.signatures.map((signature) => <div key={signature.id} className="act-signer-row">
<div className="closure-step-grid"> <div><strong>{signature.signerName}</strong><small>{signature.signerType === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa'} · {formatDate(signature.signedAt ?? signature.createdAt)}</small><p>{signatureOutcome(signature)}</p></div>
<div className={closure.responsible ? 'complete' : ''}><span>1</span><strong>Responsable</strong><small>{closure.responsible ? 'Registrado' : 'Pendiente'}</small></div> {signature.status === 'SIGNED' && <button type="button" className="button secondary" onClick={() => void viewSignature(signature)}>Ver firma</button>}
<div className={closure.closure?.isCurrent ? 'complete' : ''}><span>2</span><strong>Bloqueo</strong><small>{closure.closure?.isCurrent ? 'Contenido inmutable' : 'Pendiente'}</small></div> </div>)}</div> : <p className="muted">Las firmas todavía no se registraron.</p>}
<div className={constanciasCompletas ? 'complete' : ''}><span>3</span><strong>Manifestaciones</strong><small>{inspectorSignatures.length} inspector · {companyOutcome ? 'empresa resuelta' : 'empresa pendiente'}</small></div> {(act.status === 'SEALED' || act.status === 'CLOSED') && <p className="act-closed-date">Acta firmada y cerrada {formatDate(closure.act.sealedAt ?? closure.act.closedAt)}</p>}
<div className={isSealed ? 'complete' : ''}><span>4</span><strong>Sellado</strong><small>{isSealed ? 'Definitivo' : 'Pendiente'}</small></div> {closure.closure?.finalSha256 && <details className="act-integrity-details"><summary>Verificar integridad del Acta</summary><p>SHA-256 del cierre: <code>{closure.closure.finalSha256}</code></p></details>}
</div>
<div className="responsible-summary">
<div><small>Urgencia</small><strong>{closure.act.urgency === 'URGENT' ? 'Urgente' : closure.act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
<div><small>Plazo configurado</small><strong>{closure.act.deadlineDays ? `${closure.act.deadlineDays} días ${closure.act.deadlineDayType === 'BUSINESS' ? 'hábiles' : 'corridos'}` : 'Pendiente'}</strong></div>
<div><small>Inicio del plazo</small><strong>{closure.act.deadlineBaseAt ? formatDate(closure.act.deadlineBaseAt) : 'Pendiente de evento válido'}</strong></div>
<div><small>Vencimiento</small><strong>{closure.act.deadlineAt ? formatDate(closure.act.deadlineAt) : 'Todavía no iniciado'}</strong></div>
</div>
{closure.responsible && <div className="responsible-summary"><div><small>Situación</small><strong>{closure.responsible.attendanceStatus === 'PRESENT' ? 'Presente' : 'Ausente'}</strong></div><div><small>Responsable</small><strong>{closure.responsible.fullName ?? 'No estuvo presente'}</strong></div><div><small>Documento / cargo</small><strong>{closure.responsible.documentNumber ? `${closure.responsible.documentType} ${closure.responsible.documentNumber}` : 'No informado'}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</strong></div><div><small>Contacto</small><strong>{closure.responsible.email ?? closure.responsible.phone ?? 'No informado'}</strong></div></div>}
{closure.closure?.isCurrent && <div className="closure-hash-card"><div><span className="eyebrow">CONTENIDO BLOQUEADO</span><strong>{closure.closure.schemaVersion}</strong><small>Bloqueado {formatDate(closure.act.lockedAt ?? closure.closure.preparedAt)}</small></div><code>{closure.act.lockedSha256 ?? closure.closure.preparedSha256}</code></div>}
{closure.signatures.length > 0 && <div className="signature-records">{closure.signatures.map((signature) => <article key={signature.id}><div><span className={`status-badge ${signature.status === 'SIGNED' ? 'active' : 'observed'}`}>{signatureStatusLabel(signature.status)}</span><strong>{signature.signerName}</strong><small>{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}</small></div><code title={signature.signaturePayloadSha256}>{signature.signaturePayloadSha256}</code>{signature.status === 'SIGNED' ? <><button type="button" className="button secondary" onClick={() => viewSignature(signature)}>Ver firma</button>{signature.signerType === 'COMPANY_RESPONSIBLE' && <p><strong>{signature.companyManifestation === 'DISSENT' ? 'Firma en disidencia' : 'Firma en conformidad'}</strong>{signature.companyStatement ? ` · ${signature.companyStatement}` : ''}</p>}</> : <p>{signature.reason}</p>}</article>)}</div>}
{isSealed && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA SELLADA</span><strong>{formatDate(closure.act.sealedAt ?? closure.closure.serverClosedAt)}</strong><p>El Acta quedó sellada e inmutable. La Inspección y sus demás Actas continúan con ciclo independiente.</p><code>{closure.closure.finalSha256}</code></div></div>}
</section>; </section>;
} }
@@ -1,11 +1,11 @@
import type { InspectionActVersionEvent } from '../../lib/api'; import type { InspectionActVersionEvent } from '../../lib/api';
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
DRAFT: 'Borrador', DRAFT: 'En elaboración',
LOCKED: 'Bloqueada · esperando manifestación', LOCKED: 'Para firmar',
SEALED: 'Sellada', SEALED: 'Firmada y cerrada',
READY: 'Lista para cerrar · legado', READY: 'Lista para cerrar · legado',
CLOSED: 'Cerrada · legado', CLOSED: 'Firmada y cerrada',
CANCELLED: 'Cancelada', CANCELLED: 'Cancelada',
RECTIFIED: 'Rectificada · legado', RECTIFIED: 'Rectificada · legado',
}; };
+1 -2
View File
@@ -18,13 +18,12 @@ const operational: NavItem[] = [
]; ];
const followUp: 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' }, { to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' },
]; ];
const documents: NavItem[] = [ const documents: NavItem[] = [
{ to: '/actas', label: 'Actas', icon: 'clipboard', permission: 'inspection_acts.read' }, { 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[] = [ const master: NavItem[] = [
+4
View File
@@ -2707,6 +2707,10 @@ export function getInspectionAct(id: string) {
return apiRequest<InspectionAct>(`/inspection-acts/${id}`); return apiRequest<InspectionAct>(`/inspection-acts/${id}`);
} }
export function getInspectionActConsolidatedPdfBlob(actId: string) {
return apiBlobRequest(`/inspection-acts/${actId}/consolidated-pdf`);
}
export function getInspectionActPdfBlob(actId: string) { export function getInspectionActPdfBlob(actId: string) {
return apiBlobRequest(`/inspection-acts/${actId}/pdf`); return apiBlobRequest(`/inspection-acts/${actId}/pdf`);
} }
+8
View File
@@ -205,3 +205,11 @@ export function inspectionReportWordDownloadUrl(id: string) {
export function inspectionReportGedoPdfDownloadUrl(id: string) { export function inspectionReportGedoPdfDownloadUrl(id: string) {
return `/api/v3/inspection-reports/${id}/gedo-pdf`; 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`;
}
@@ -1,20 +1,31 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; 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 type { ActAdministrationDetail } from '../lib/api';
import { getInspectionActF4 } from '../lib/inspectionActF4Api';
import { formatDate } from '../lib/format'; import { formatDate } from '../lib/format';
export function ActAdministrationDetailPage() { export function ActAdministrationDetailPage() {
const {actId=''}=useParams(); const [data,setData]=useState<ActAdministrationDetail|null>(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<File|undefined>(); const { actId = '' } = useParams();
const load=()=>getActAdministration(actId).then(setData).catch(e=>setError(errorMessage(e))); useEffect(()=>{void load()},[actId]); const [data, setData] = useState<ActAdministrationDetail | null>(null);
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 [reportId, setReportId] = useState<string | null>(null);
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)}}; const [error, setError] = useState('');
if(!data&&!error)return <LoadingBlock label="Cargando expediente administrativo…"/>; useEffect(() => {
return <section>{error&&<Alert>{error}</Alert>}{data&&<><div className="page-heading"><div><span className="eyebrow">EXPEDIENTE ADMINISTRATIVO</span><h1>{data.act.actCode}</h1><p>{data.act.areaName??'Área sin asignar'} · {data.act.companyName??'Empresa sin asignar'} · {data.act.openFindingCount} hallazgos abiertos</p></div><Link className="button secondary" to={`/inspecciones/actas/${data.act.actId}`}>Ver Acta</Link></div> Promise.all([getActAdministration(actId), getInspectionActF4(actId)])
<div className="detail-grid"><div className="card"><h2>Plazo de respuesta</h2><p>El plazo aplica al conjunto completo de hallazgos del Acta.</p><form className="form-grid" onSubmit={deadline}><label><span>Vencimiento *</span><input type="date" required value={due} onChange={e=>setDue(e.target.value)}/></label><label className="full"><span>Motivo *</span><textarea required minLength={3} value={reason} onChange={e=>setReason(e.target.value)}/></label><button className="button primary" disabled={busy}>Registrar nuevo plazo</button></form>{data.deadlines.length>0&&<div className="stack-list">{data.deadlines.map(d=><div key={d.id}><strong>{formatDate(d.responseDueOn)}</strong><small>{d.reason}</small></div>)}</div>}</div> .then(([detail, act]) => { setData(detail); setReportId(act.report?.id ?? null); })
<div className="card"><h2>Respuesta de la empresa</h2><form className="form-grid" onSubmit={response}><label><span>Recibida el *</span><input type="date" required value={received} onChange={e=>setReceived(e.target.value)}/></label><label><span>Fecha comprometida</span><input type="date" value={commitment} onChange={e=>setCommitment(e.target.value)}/></label><label className="full"><span>Detalle</span><textarea value={details} onChange={e=>setDetails(e.target.value)} placeholder="Resumen de la presentación de la empresa"/></label><label className="full"><span>PDF presentado</span><input type="file" accept="application/pdf,.pdf" onChange={e=>setFile(e.target.files?.[0])}/></label><button className="button primary" disabled={busy}>Registrar respuesta</button></form>{data.responses.length>0&&<div className="stack-list">{data.responses.map(r=><div key={r.id}><strong>{formatDate(r.receivedOn)}</strong><small>{r.details??'Sin detalle'}{r.committedCorrectionOn?` · compromiso ${formatDate(r.committedCorrectionOn)}`:''}</small>{r.originalName&&<a className="text-link" href={actCompanyResponseContentUrl(r.id)} target="_blank" rel="noreferrer">Abrir PDF</a>}</div>)}</div>}</div></div> .catch((requestError) => setError(errorMessage(requestError)));
<div className="card"><h2>Hallazgos del Acta</h2><div className="table-scroll"><table><thead><tr><th>Código</th><th>Hallazgo</th><th>Estado</th><th>Próximo control</th></tr></thead><tbody>{data.findings.map(f=><tr key={f.id}><td><Link className="text-link" to={`/hallazgos/${f.id}`}>{f.code}</Link></td><td>{f.title}</td><td>{f.status}</td><td>{f.nextControlOn?formatDate(f.nextControlOn):'Sin programar'}</td></tr>)}</tbody></table></div></div></>}</section>; }, [actId]);
if (!data && !error) return <LoadingBlock label="Cargando histórico…" />;
return <section>
{error && <Alert>{error}</Alert>}
{data && <>
<div className="page-heading"><div><span className="eyebrow">HISTÓRICO ADMINISTRATIVO</span><h1>{data.act.actCode}</h1><p>Registro previo de plazos y respuestas asociados al Acta.</p></div><Link className="button secondary" to={`/inspecciones/actas/${actId}`}>Ver Acta</Link></div>
{reportId && <p>Las nuevas respuestas y verificaciones se registran en el <Link to={`/informes/${reportId}`}>Informe relacionado</Link>.</p>}
<section className="panel"><h2>Antecedentes conservados</h2>
{!data.deadlines.length && !data.responses.length && <p>No hay antecedentes administrativos anteriores.</p>}
{[...data.deadlines.map((item) => ({ id: item.id, date: item.createdAt, title: 'Plazo registrado', description: `${formatDate(item.responseDueOn)} · ${item.reason}`, fileId: null as string | null, fileName: null as string | null })), ...data.responses.map((item) => ({ id: item.id, date: item.receivedOn, title: 'Respuesta de la empresa', description: item.details ?? 'Sin descripción', fileId: item.id, fileName: item.originalName }))].sort((a, b) => b.date.localeCompare(a.date)).map((item) => <div key={item.id} className="act-signer-row"><div><strong>{item.title}</strong><small>{formatDate(item.date)}</small><p>{item.description}</p></div>{item.fileName && item.fileId && <a className="button secondary" href={actCompanyResponseContentUrl(item.fileId)} target="_blank" rel="noreferrer">Abrir {item.fileName}</a>}</div>)}
</section>
</>}
</section>;
} }
+4 -4
View File
@@ -18,9 +18,9 @@ import { formatDate } from '../lib/format';
const statuses: Array<{ value: InspectionActStatusF4 | ''; label: string }> = [ const statuses: Array<{ value: InspectionActStatusF4 | ''; label: string }> = [
{ value: '', label: 'Todos los estados' }, { value: '', label: 'Todos los estados' },
{ value: 'DRAFT', label: 'Borrador' }, { value: 'DRAFT', label: 'En elaboración' },
{ value: 'LOCKED', label: 'Bloqueada · esperando manifestación' }, { value: 'LOCKED', label: 'Para firmar' },
{ value: 'SEALED', label: 'Sellada' }, { value: 'SEALED', label: 'Firmada y cerrada' },
{ value: 'CANCELLED', label: 'Cancelada' }, { value: 'CANCELLED', label: 'Cancelada' },
{ value: 'READY', label: 'Lista para cerrar · legado' }, { value: 'READY', label: 'Lista para cerrar · legado' },
{ value: 'CLOSED', label: 'Cerrada · legado' }, { value: 'CLOSED', label: 'Cerrada · legado' },
@@ -112,7 +112,7 @@ export function ActsPage() {
{loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table"> {loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table">
<div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div> <div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}> <div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}>
<td><div className="document-primary"><strong>{act.code}</strong><small>{act.title} · {formatDate(act.occurredAt)}</small></div></td> <td><div className="document-primary"><strong>{act.code}</strong><small>{formatDate(act.occurredAt)}</small></div></td>
<td><div className="document-primary"><strong>{contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(act.areas, 'Área sin asignar')}</small></div></td> <td><div className="document-primary"><strong>{contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td> <td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td> <td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td>
+12 -21
View File
@@ -5,13 +5,10 @@ import { Icon } from '../components/Icon';
import { import {
inspectionActStatusClass, inspectionActStatusClass,
inspectionActStatusLabel, inspectionActStatusLabel,
inspectionActVersionEventLabel,
} from '../features/inspections/inspectionActPresentation'; } from '../features/inspections/inspectionActPresentation';
import { InspectionClosurePanel } from '../features/inspections/InspectionClosurePanel'; import { InspectionClosurePanel } from '../features/inspections/InspectionClosurePanel';
import { InspectionFindingsPanel } from '../features/inspections/InspectionFindingsPanel';
import { InspectionActMediaPanel } from '../features/inspections/InspectionActMediaPanel'; import { InspectionActMediaPanel } from '../features/inspections/InspectionActMediaPanel';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection'; import { getInspectionActConsolidatedPdfBlob, getInspectionVisit } from '../lib/api';
import { getInspectionActPdfBlob, getInspectionVisit } from '../lib/api';
import type { InspectionVisit } from '../lib/api'; import type { InspectionVisit } from '../lib/api';
import { getInspectionActF4, type InspectionActF4 } from '../lib/inspectionActF4Api'; import { getInspectionActF4, type InspectionActF4 } from '../lib/inspectionActF4Api';
import { formatDate } from '../lib/format'; import { formatDate } from '../lib/format';
@@ -49,12 +46,13 @@ export function InspectionActEditorPage() {
if (loading) return <LoadingBlock label="Cargando acta…" />; if (loading) return <LoadingBlock label="Cargando acta…" />;
const isSealed = act?.status === 'SEALED' || act?.status === 'CLOSED'; const isSealed = act?.status === 'SEALED' || act?.status === 'CLOSED';
const meaningfulSummary = act?.summary && !act.summary.startsWith('Acta de inspección en curso. Los Hallazgos') ? act.summary : null;
const openActPdf = async (download: boolean) => { const openActPdf = async (download: boolean) => {
if (!act) return; if (!act) return;
setPdfBusy(true); setError(''); setPdfBusy(true); setError('');
try { try {
const blob = await getInspectionActPdfBlob(act.id); const blob = await getInspectionActConsolidatedPdfBlob(act.id);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (download) { if (download) {
const link = document.createElement('a'); const link = document.createElement('a');
@@ -75,38 +73,31 @@ export function InspectionActEditorPage() {
return <section className="survey-editor inspection-act-editor"> return <section className="survey-editor inspection-act-editor">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span>{visit && <><Link to={`/inspecciones/${visit.id}`}>{visit.code}</Link><span>/</span></>}<span>{act?.code ?? 'Acta'}</span></div> <div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span>{visit && <><Link to={`/inspecciones/${visit.id}`}>{visit.code}</Link><span>/</span></>}<span>{act?.code ?? 'Acta'}</span></div>
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">ACTA DE INSPECCIÓN</span><h1>{act?.code ?? 'Acta'}</h1><p>{visit ? `${visit.code} · ${visit.scopeAsset?.name ?? visit.operationalArea?.name ?? 'Inspección'} · versión ${act?.currentVersion ?? ''}` : 'Consulta del documento sincronizado desde la APK.'}</p></div>{act && <span className={`status-badge large ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span>}</div> <div className="page-heading survey-editor-heading"><div><span className="eyebrow">ACTA DE INSPECCIÓN</span><h1>{act?.code ?? 'Acta'}</h1><p>{visit ? `${visit.code} · ${visit.scopeAsset?.name ?? visit.operationalArea?.name ?? 'Inspección'}` : 'Consulta del Acta.'}</p></div>{act && <span className={`status-badge large ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span>}</div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{act && <section className="panel act-document-primary"> {act && <section className="panel act-document-primary">
<div className="act-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'PDF del Acta disponible' : 'PDF pendiente de cierre'}</h2><p>{isSealed ? 'Abrí o descargá el Acta completa firmada y sellada.' : 'El PDF definitivo se genera cuando el Acta queda firmada y cerrada.'}</p></div></div> <div className="act-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'Acta consolidada disponible' : 'Acta en preparación'}</h2><p>{isSealed ? 'Abrí o descargá el Acta firmada, con sus Hallazgos y constancias de integridad.' : 'El documento definitivo se genera al firmar y cerrar el Acta.'}</p></div></div>
<div className="act-primary-actions">{isSealed ? <><button className="button primary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(false)}>{pdfBusy ? 'Preparando…' : 'Abrir PDF del Acta'}</button><button className="button secondary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(true)}>Descargar PDF</button></> : <span className="status-badge pending">{inspectionActStatusLabel(act.status)}</span>}</div> <div className="act-primary-actions">{isSealed ? <><button className="button primary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(false)}>{pdfBusy ? 'Preparando…' : 'Abrir PDF del Acta'}</button><button className="button secondary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(true)}>Descargar PDF</button></> : <span className="status-badge pending">{inspectionActStatusLabel(act.status)}</span>}</div>
</section>} </section>}
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? ' El informe está disponible.' : ' El INF continúa en preparación.'}</p></div>} {act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? ' Disponible.' : ' En preparación.'}</p></div>}
{isSealed && act && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>INF pendiente de emisión.</strong> El Acta ya está sellada y disponible como documento fuente.</p></div>} {isSealed && act && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>INF pendiente de emisión.</strong> El Acta ya está sellada y disponible como documento fuente.</p></div>}
{act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>} {act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>}
{act && <section className="panel inspection-act-form"> {act && <section className="panel inspection-act-form">
<div className="panel-heading"><div><span className="eyebrow">CONTENIDO SINCRONIZADO</span><h2>{act.code}</h2></div><small className="muted">Actualizado {formatDate(act.updatedAt)}</small></div>
<div className="responsible-summary"> <div className="responsible-summary">
<div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div> <div><small>Fecha de inspección</small><strong>{formatDate(act.occurredAt)}</strong></div>
<div><small>Estado documental</small><strong>{inspectionActStatusLabel(act.status)}</strong></div> <div><small>Estado</small><strong>{inspectionActStatusLabel(act.status)}</strong></div>
<div><small>Urgencia</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div> <div><small>Urgencia del Acta</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : act.urgency === 'NON_URGENT' ? 'No urgente' : 'Se define al cerrar el contenido'}</strong></div>
<div><small>Hallazgos</small><strong>{act.findingCount}</strong></div> <div><small>Hallazgos</small><strong>{act.findingCount}</strong></div>
</div> </div>
<div className="closure-section"><span className="eyebrow">DESCRIPCIÓN DE LO ACTUADO</span><p>{act.summary}</p>{act.observations && <><span className="eyebrow">OBSERVACIONES</span><p>{act.observations}</p></>}</div> {meaningfulSummary && <div><h2>Lo actuado</h2><p>{meaningfulSummary}</p></div>}
<div className="inspection-act-assets"><div><span className="eyebrow">INVENTARIO DEL ACTA</span><h3>Referencias congeladas</h3></div><span className="count-pill">{act.assets.length}</span></div> {act.observations && <div><h3>Observaciones</h3><p>{act.observations}</p></div>}
<div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div> {act.assets.length > 0 && <div><h3>Instalaciones inspeccionadas</h3><p>{act.assets.map((asset) => `${asset.name} (${asset.code})`).join(' · ')}</p></div>}
</section>} </section>}
{act && <InspectionActMediaPanel actId={act.id} />} {act && <InspectionActMediaPanel actId={act.id} />}
{act && <details className="legacy-projection-details"><summary>Ver estructura documental de referencia</summary><DocumentPdfProjection kind="act" /></details>}
{act && <InspectionFindingsPanel act={act} />}
{act && <InspectionClosurePanel act={act} />} {act && <InspectionClosurePanel act={act} />}
{act && <section className="panel survey-report-history">
<div className="panel-heading"><div><span className="eyebrow">VERSIONES INMUTABLES</span><h2>Historial exacto del acta</h2><p className="section-copy">Cada transición conserva el contenido y las versiones de los registros referenciados.</p></div><span className="count-pill">{act.versions.length}</span></div>
<div className="survey-version-list">{act.versions.map((version) => <details key={version.id}><summary><span className="count-pill">v{version.versionNumber}</span><strong>{inspectionActVersionEventLabel(version.event)}</strong><small>{formatDate(version.createdAt)} · {version.actorUsername ?? 'sistema'}</small></summary><pre>{JSON.stringify(version.snapshot, null, 2)}</pre></details>)}</div>
</section>}
</section>; </section>;
} }
+24 -16
View File
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection'; import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api';
import type { ActAdministrationDetail } from '../lib/api';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
@@ -11,6 +12,8 @@ import {
addInspectionReportFollowUp, addInspectionReportFollowUp,
getInspectionReportF4, getInspectionReportF4,
inspectionReportGedoPdfDownloadUrl, inspectionReportGedoPdfDownloadUrl,
inspectionReportFollowUpDownloadUrl,
inspectionReportConsolidatedWordDownloadUrl,
inspectionReportWordDownloadUrl, inspectionReportWordDownloadUrl,
listInspectionReportFollowUps, listInspectionReportFollowUps,
officializeInspectionReport, officializeInspectionReport,
@@ -63,8 +66,10 @@ export function ReportDetailPage() {
const { id } = useParams(); const { id } = useParams();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canManage = hasPermission('inspection_reports.generate'); const canManage = hasPermission('inspection_reports.generate');
const canReadActHistory = hasPermission('inspection_acts.read');
const [report, setReport] = useState<InspectionReportDetailF4 | null>(null); const [report, setReport] = useState<InspectionReportDetailF4 | null>(null);
const [followUps, setFollowUps] = useState<InspectionReportFollowUp[]>([]); const [followUps, setFollowUps] = useState<InspectionReportFollowUp[]>([]);
const [legacy, setLegacy] = useState<ActAdministrationDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -91,6 +96,7 @@ export function ReportDetailPage() {
]); ]);
setReport(nextReport); setReport(nextReport);
setFollowUps(nextFollowUps); setFollowUps(nextFollowUps);
if (canReadActHistory) setLegacy(await getActAdministration(nextReport.actId).catch(() => null));
setExecutiveSummary(nextReport.executiveSummary ?? ''); setExecutiveSummary(nextReport.executiveSummary ?? '');
setReportDescription(nextReport.reportDescription ?? ''); setReportDescription(nextReport.reportDescription ?? '');
setGedoIfIdentifier(nextReport.gedoIfIdentifier ?? ''); setGedoIfIdentifier(nextReport.gedoIfIdentifier ?? '');
@@ -180,6 +186,16 @@ export function ReportDetailPage() {
if (loading) return <LoadingBlock label="Cargando informe…" />; if (loading) return <LoadingBlock label="Cargando informe…" />;
const timeline: Array<{ id: string; date: string; title: string; description: string; href?: string; fileName?: string }> = report ? [
{ id: 'act-start', date: report.act.occurredAt, title: `Inspección y Acta ${report.act.code}`, description: `${report.findingCount} hallazgo${report.findingCount === 1 ? '' : 's'} registrados`, href: `/inspecciones/actas/${report.actId}` },
...(report.act.sealedAt ? [{ id: 'act-sealed', date: report.act.sealedAt, title: 'Acta firmada y cerrada', description: report.act.code, href: `/inspecciones/actas/${report.actId}` }] : []),
{ id: 'report-issued', date: report.generatedAt, title: `Informe ${report.code} preparado`, description: 'Documento técnico vinculado al Acta' },
...(report.gedoOfficializedAt ? [{ id: 'gedo', date: report.gedoOfficializedAt, title: 'Informe oficializado en GEDO', description: report.gedoIfIdentifier ?? '' }] : []),
...followUps.map((item) => ({ id: item.id, date: item.occurredAt, title: followUpLabel(item.type), description: item.description || item.externalReference || item.originalName || 'Antecedente registrado', href: item.originalName ? inspectionReportFollowUpDownloadUrl(report.id, item.id) : undefined, fileName: item.originalName ?? undefined })),
...(legacy?.responses.map((item) => ({ id: `legacy-${item.id}`, date: item.receivedOn, title: 'Respuesta de empresa registrada previamente', description: item.details ?? 'Sin detalle', href: item.originalName ? actCompanyResponseContentUrl(item.id) : undefined, fileName: item.originalName ?? undefined })) ?? []),
...(legacy?.deadlines.map((item) => ({ id: `deadline-${item.id}`, date: item.createdAt, title: 'Plazo administrativo registrado previamente', description: `${formatDate(item.responseDueOn)} · ${item.reason}` })) ?? []),
].sort((a, b) => b.date.localeCompare(a.date)) : [];
return <section> return <section>
<div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div> <div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div>
<div className="page-heading survey-editor-heading"> <div className="page-heading survey-editor-heading">
@@ -211,10 +227,9 @@ export function ReportDetailPage() {
</div> </div>
</section> </section>
<DocumentPdfProjection kind="report" />
<section className="panel"> <section className="panel">
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Descargar Word</a>}</div> <div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div><div className="act-primary-actions"><a className="button secondary" href={inspectionReportConsolidatedWordDownloadUrl(report.id)}>Descargar Word del informe</a>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Word anterior</a>}</div></div>
<form className="form-section" onSubmit={saveNarrative}> <form className="form-section" onSubmit={saveNarrative}>
<label className="field"><span>Resumen ejecutivo</span><textarea rows={4} maxLength={20000} value={executiveSummary} onChange={(event) => setExecutiveSummary(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Resumen ejecutivo del Informe…" /></label> <label className="field"><span>Resumen ejecutivo</span><textarea rows={4} maxLength={20000} value={executiveSummary} onChange={(event) => setExecutiveSummary(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Resumen ejecutivo del Informe…" /></label>
<label className="field"><span>Descripción / análisis técnico</span><textarea rows={8} maxLength={50000} value={reportDescription} onChange={(event) => setReportDescription(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Descripción técnica, análisis y consideraciones del Inspector…" /></label> <label className="field"><span>Descripción / análisis técnico</span><textarea rows={8} maxLength={50000} value={reportDescription} onChange={(event) => setReportDescription(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Descripción técnica, análisis y consideraciones del Inspector…" /></label>
@@ -246,21 +261,14 @@ export function ReportDetailPage() {
</section> </section>
<section className="panel"> <section className="panel">
<div className="panel-heading"><div><span className="eyebrow">SEGUIMIENTO DEL INF</span><h2>Presentaciones y antecedentes</h2><p className="section-copy">Las respuestas de empresa, documentos, notas internas y verificaciones se agregan cronológicamente. Nunca reemplazan un antecedente anterior.</p></div><span className="count-pill">{followUps.length}</span></div> <div className="panel-heading"><div><span className="eyebrow">INFORME Y RESPUESTAS</span><h2>Historia y presentaciones</h2><p className="section-copy">La historia del Acta y las respuestas posteriores se leen en orden. Las nuevas respuestas se registran en este Informe.</p></div></div>
<div className="dossier-link-list">{timeline.map((item) => <div key={item.id}>
{followUps.length === 0 ? <div className="inline-empty">Todavía no hay antecedentes posteriores registrados.</div> : <div className="dossier-link-list"> <div><strong>{item.title}</strong><small>{item.description}</small>{item.href && (item.fileName ? <a className="text-link" href={item.href}>Descargar {item.fileName}</a> : <Link className="text-link" to={item.href}>Ver Acta</Link>)}</div>
{[...followUps].reverse().map((item) => <div key={item.id}> <span>{formatDate(item.date)}</span>
<div> </div>)}</div>
<strong>{followUpLabel(item.type)}</strong>
<small>{item.description || item.externalReference || item.originalName || 'Sin descripción'}</small>
{item.originalName && <small>Archivo: {item.originalName}{item.sizeBytes ? ` · ${fileSize(item.sizeBytes)}` : ''}</small>}
</div>
<span>{formatDate(item.occurredAt)}{item.externalReference ? ` · ${item.externalReference}` : ''}</span>
</div>)}
</div>}
{canManage && <form className="form-section" onSubmit={addFollowUp}> {canManage && <form className="form-section" onSubmit={addFollowUp}>
<div><h3>Agregar antecedente</h3><p className="section-copy">Usá este bloque para registrar una nueva presentación sin modificar las anteriores.</p></div> <div><h3>Registrar respuesta o antecedente</h3><p className="section-copy">La respuesta queda asociada a este Informe y conserva los registros anteriores.</p></div>
<div className="form-grid"> <div className="form-grid">
<label className="field"><span>Tipo</span><SearchableSelect value={followUpType} onChange={(event) => setFollowUpType(event.target.value as InspectionReportFollowUpType)}><option value="COMPANY_NOTE">Presentación / nota de empresa</option><option value="COMPANY_DOCUMENT">Documento de empresa</option><option value="INTERNAL_NOTE">Nota interna</option><option value="VERIFICATION">Verificación</option><option value="OTHER">Otro antecedente</option></SearchableSelect></label> <label className="field"><span>Tipo</span><SearchableSelect value={followUpType} onChange={(event) => setFollowUpType(event.target.value as InspectionReportFollowUpType)}><option value="COMPANY_NOTE">Presentación / nota de empresa</option><option value="COMPANY_DOCUMENT">Documento de empresa</option><option value="INTERNAL_NOTE">Nota interna</option><option value="VERIFICATION">Verificación</option><option value="OTHER">Otro antecedente</option></SearchableSelect></label>
<label className="field"><span>Fecha</span><input type="datetime-local" value={followUpOccurredAt} onChange={(event) => setFollowUpOccurredAt(event.target.value)} required /></label> <label className="field"><span>Fecha</span><input type="datetime-local" value={followUpOccurredAt} onChange={(event) => setFollowUpOccurredAt(event.target.value)} required /></label>
+17
View File
@@ -1416,3 +1416,20 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
.legacy-projection-details > summary::-webkit-details-marker { display: none; } .legacy-projection-details > summary::-webkit-details-marker { display: none; }
.legacy-projection-details[open] > summary { border-bottom: 1px solid var(--line); } .legacy-projection-details[open] > summary { border-bottom: 1px solid var(--line); }
.legacy-projection-details > .document-pdf-projection { margin: 12px; } .legacy-projection-details > .document-pdf-projection { margin: 12px; }
/* Lectura consolidada del Acta */
.act-finding-list { display: grid; gap: 22px; }
.act-finding-record { border-top: 1px solid var(--border); padding-top: 20px; display: grid; gap: 8px; }
.act-finding-record:first-child { border-top: 0; padding-top: 0; }
.act-finding-record p { margin: 0; line-height: 1.55; }
.act-finding-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-top: 8px; }
.act-finding-photo { margin: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 10px; }
.act-finding-photo img { display: block; width: 100%; height: 190px; object-fit: cover; }
.act-finding-photo figcaption { padding: 10px; color: var(--muted); font-size: 11px; }
.act-signer-list { display: grid; gap: 10px; }
.act-signer-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; border-top: 1px solid var(--border); padding-top: 12px; }
.act-signer-row small { display: block; margin-top: 3px; color: var(--muted); }
.act-signer-row p { margin: 5px 0 0; }
.act-closed-date { font-weight: 700; }
.act-integrity-details { border-top: 1px solid var(--border); padding-top: 10px; }
.act-integrity-details code { overflow-wrap: anywhere; }