diff --git a/CHANGELOG.md b/CHANGELOG.md index 034347f..b28c698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Toutes les versions notables de TechIBase sont documentées ici. Le projet est e - **Cycle de maintenance et rappels** : un équipement peut avoir un cycle de maintenance (mensuel, trimestriel, semestriel, annuel, tous les 2 ans). Quand un bon de service de maintenance (généré automatiquement ou prévu à la date de prochaine maintenance) passe à « Terminé », la dernière maintenance est enregistrée et la prochaine est reportée d'autant. L'accueil affiche les listes « Maintenances en retard / à venir (30 j) » et « EPI expirés / bientôt expirés (30 j) ». La tâche quotidienne crée des notifications pour les administrateurs (sans doublon sur 7 jours) et le résumé par courriel (si configuré) inclut les maintenances à venir et les EPI expirés. Nouvelle colonne `equipment.maintenance_interval_months` (ajoutée automatiquement). - **« Ma journée » (mobile)** : une page `/journee` (entrée « Ma journée » du menu) liste, pour le technicien connecté, ses bons de service en cours, du jour et en retard, plus ceux terminés aujourd'hui, avec le client, l'adresse (ouvre la carte), le téléphone (appel en un geste), les boutons **Démarrer** / **Terminer**, une note rapide ajoutée au journal du client et un lien vers les bons du client. Sur téléphone, un utilisateur non administrateur arrive sur cette page à l'ouverture de l'application. Nouvelles routes `GET /api/work-orders/my-day` et `POST /api/work-orders/:id/status` (réservée à la personne assignée ou à un administrateur). +- **Rapports d'intervention signés (PDF)** : depuis un bon de service, le technicien rédige un rapport (travaux effectués, pièces, temps passé, observations), le client signe à l'écran (zone de signature tactile ou souris, image PNG limitée à 64 Ko et vérifiée côté serveur), puis l'application génère un PDF (en-tête, client, bon de service, rapport, signature, date) à télécharger ou à joindre aux documents du client. Réservé à l'administrateur, au technicien assigné et au créateur du bon. Nouvelle table `service_reports` ; nouvelle dépendance `pdfkit` (JavaScript pur). Le nom en en-tête se règle avec `COMPANY_NAME` (par défaut « TechIBase »). La génération du PDF n'est pas disponible sur le déploiement Netlify (réponse 501) ; la rédaction et la signature y fonctionnent. ## [0.0.14] - Mises à jour en un clic (publication corrigée) diff --git a/backend/db/init.sql b/backend/db/init.sql index 4ed260d..43e3800 100644 --- a/backend/db/init.sql +++ b/backend/db/init.sql @@ -130,6 +130,22 @@ CREATE TABLE IF NOT EXISTS work_orders ( CONSTRAINT work_orders_priority_check CHECK (priority IN ('low', 'medium', 'high', 'urgent')) ); +CREATE TABLE IF NOT EXISTS service_reports ( + id SERIAL PRIMARY KEY, + work_order_id INTEGER NOT NULL UNIQUE REFERENCES work_orders(id) ON DELETE CASCADE, + work_done TEXT, + parts_used TEXT, + time_spent_minutes INTEGER, + observations TEXT, + signer_name VARCHAR(255), + signature BYTEA, + signed_at TIMESTAMP, + document_id INTEGER REFERENCES documents(id) ON DELETE SET NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS notifications ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/backend/package-lock.json b/backend/package-lock.json index 5f66c90..5db18e8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "express-rate-limit": "^7.4.1", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "pdfkit": "^0.15.2", "pg": "^8.11.3" } }, @@ -27,6 +28,15 @@ "node": "^14.16.0 || >=16.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz", + "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -46,12 +56,63 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -85,6 +146,24 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -117,6 +196,24 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -146,6 +243,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -220,6 +326,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -229,6 +342,78 @@ "ms": "2.0.0" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-equal/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -248,6 +433,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -304,6 +495,32 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -410,6 +627,38 @@ "node": ">= 0.8" } }, + "node_modules/fontkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz", + "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.3.13", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "deep-equal": "^2.0.5", + "dfa": "^1.2.0", + "restructure": "^2.0.1", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -437,6 +686,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -486,6 +744,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -498,6 +780,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -548,6 +845,20 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -557,12 +868,245 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -612,6 +1156,25 @@ "safe-buffer": "^5.0.1" } }, + "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/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -799,6 +1362,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -811,6 +1419,12 @@ "node": ">= 0.8" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -826,6 +1440,19 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.2.tgz", + "integrity": "sha512-s3GjpdBFSCaeDSX/v73MI5UsPqH1kjKut2AXCgxQ5OH10lPVOu5q5vLAG0OCpz/EYqKsTSw1WHpENqMvp43RKg==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^1.8.1", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.0.2", + "png-js": "^1.0.0" + } + }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", @@ -915,6 +1542,23 @@ "split2": "^4.1.0" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1038,6 +1682,32 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/restructure": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz", + "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1058,6 +1728,23 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1121,6 +1808,38 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1217,6 +1936,19 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -1240,6 +1972,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "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/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1249,6 +1987,12 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1268,6 +2012,32 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "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/unicode-trie/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/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1301,6 +2071,64 @@ "node": ">= 0.8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 86c32f6..a89af09 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ "express-rate-limit": "^7.4.1", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "pdfkit": "^0.15.2", "pg": "^8.11.3" } } diff --git a/backend/src/app.js b/backend/src/app.js index 30b1f74..3bb0c35 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -54,6 +54,7 @@ const logbookRouter = require('./routes/logbook'); const documentsRouter = require('./routes/documents'); const searchRouter = require('./routes/search'); const workOrdersRouter = require('./routes/workorders'); +const serviceReportsRouter = require('./routes/serviceReports'); const notificationsRouter = require('./routes/notifications'); const dashboardRouter = require('./routes/dashboard'); const myDayRouter = require('./routes/myday'); @@ -73,6 +74,7 @@ app.use('/api', documentsRouter); app.use('/api', searchRouter); app.use('/api', workOrdersRouter); app.use('/api', myDayRouter); +app.use('/api', serviceReportsRouter); app.use('/api', notificationsRouter); app.use('/api', dashboardRouter); app.use('/api', integrationsRouter); diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index dab3bab..820b11c 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -136,6 +136,22 @@ CREATE TABLE IF NOT EXISTS work_orders ( CONSTRAINT work_orders_priority_check CHECK (priority IN ('low', 'medium', 'high', 'urgent')) ); +CREATE TABLE IF NOT EXISTS service_reports ( + id SERIAL PRIMARY KEY, + work_order_id INTEGER NOT NULL UNIQUE REFERENCES work_orders(id) ON DELETE CASCADE, + work_done TEXT, + parts_used TEXT, + time_spent_minutes INTEGER, + observations TEXT, + signer_name VARCHAR(255), + signature BYTEA, + signed_at TIMESTAMP, + document_id INTEGER REFERENCES documents(id) ON DELETE SET NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS notifications ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/backend/src/lib/serviceReport.js b/backend/src/lib/serviceReport.js new file mode 100644 index 0000000..dc20b8d --- /dev/null +++ b/backend/src/lib/serviceReport.js @@ -0,0 +1,218 @@ +// Service reports: signature validation and PDF generation. +// pdfkit is pure JS (its Helvetica metrics ship in the package), so it runs in +// the stock node:alpine image with no system libraries. + +const zlib = require('zlib'); + +const SIGNATURE_MAX_BYTES = 64 * 1024; // decoded PNG; the JSON body limit (100 kB) is the outer bound +const SIGNATURE_MAX_DIMENSION = 2000; // px, per side +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const DATA_URL_RE = /^data:image\/png;base64,([A-Za-z0-9+/]+={0,2})$/; + +class SignatureError extends Error {} + +// Channels per colour type, and the bit depths the PNG spec allows for each. +const PNG_COLOR_TYPES = { 0: [1, [1, 2, 4, 8, 16]], 2: [3, [8, 16]], 3: [1, [1, 2, 4, 8]], 4: [2, [8, 16]], 6: [4, [8, 16]] }; +const PNG_MAX_RAW_BYTES = 8 * 1024 * 1024; // decompressed pixel data + +// Walks the chunk list and inflates the pixel data with a hard output cap, so +// that a tiny file declaring a huge image (decompression bomb), a truncated or +// corrupt stream (which would make every PDF render fail later), or bytes +// appended after IEND (polyglot) are all refused at save time. +function validatePngStructure(buf, width, height) { + const bad = () => new SignatureError('La signature doit être une image PNG valide'); + const ihdrLen = buf.readUInt32BE(8); + if (ihdrLen !== 13) throw bad(); + const depth = buf[24]; + const colorType = buf[25]; + const spec = PNG_COLOR_TYPES[colorType]; + if (!spec || !spec[1].includes(depth) || buf[26] !== 0 || buf[27] !== 0 || buf[28] !== 0) throw bad(); // no interlacing + const rowBytes = Math.ceil((width * spec[0] * depth) / 8) + 1; + const rawSize = rowBytes * height; + if (rawSize > PNG_MAX_RAW_BYTES) throw new SignatureError('Dimensions de signature invalides'); + + const idat = []; + let hasPlte = false; + let pos = 8; + let ended = false; + while (pos + 12 <= buf.length) { + const len = buf.readUInt32BE(pos); + const type = buf.toString('latin1', pos + 4, pos + 8); + const end = pos + 12 + len; + if (end > buf.length) throw bad(); + if (type === 'IDAT') idat.push(buf.subarray(pos + 8, pos + 8 + len)); + else if (type === 'PLTE') hasPlte = true; + pos = end; + if (type === 'IEND') { ended = true; break; } + } + if (!ended || pos !== buf.length || idat.length === 0 || (colorType === 3 && !hasPlte)) throw bad(); + + let raw; + try { + raw = zlib.inflateSync(Buffer.concat(idat), { maxOutputLength: rawSize }); + } catch { + throw bad(); + } + if (raw.length !== rawSize) throw bad(); +} + +// Accepts a "data:image/png;base64,..." string and returns the PNG bytes, or +// throws SignatureError (message in French, safe to show to the user). The +// bytes are checked, not just the declared type: PNG signature + a sane IHDR. +function parseSignatureDataUrl(value) { + if (typeof value !== 'string') throw new SignatureError('Signature invalide'); + // Bound the work before decoding anything. + if (value.length > Math.ceil((SIGNATURE_MAX_BYTES * 4) / 3) + 64) throw new SignatureError('Signature trop volumineuse'); + const match = DATA_URL_RE.exec(value); + if (!match) throw new SignatureError('La signature doit être une image PNG'); + const buf = Buffer.from(match[1], 'base64'); + if (buf.length > SIGNATURE_MAX_BYTES) throw new SignatureError('Signature trop volumineuse'); + if (buf.length < 33 || !buf.subarray(0, 8).equals(PNG_MAGIC) || buf.toString('latin1', 12, 16) !== 'IHDR') { + throw new SignatureError('La signature doit être une image PNG'); + } + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + if (!width || !height || width > SIGNATURE_MAX_DIMENSION || height > SIGNATURE_MAX_DIMENSION) { + throw new SignatureError('Dimensions de signature invalides'); + } + validatePngStructure(buf, width, height); + return buf; +} + +// The built-in Helvetica only has the Windows-1252 repertoire (all French +// accents, œ, €, « » are in it). Anything else (CJK, Cyrillic, emoji) would come +// out as unrelated garbage glyphs, so it is replaced by "?" instead; control +// characters other than newline and tab are dropped. +// Spelled out instead of using TextDecoder('windows-1252'): depending on the +// Node/ICU build that decoder maps 0x80-0x9F to control characters, which +// would drop œ, € and Œ. +const WIN_1252 = (() => { + const set = new Set(); + for (let c = 0x20; c <= 0x7e; c++) set.add(String.fromCharCode(c)); + for (let c = 0xa0; c <= 0xff; c++) set.add(String.fromCharCode(c)); + for (const ch of '€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ') set.add(ch); + return set; +})(); +function pdfText(value) { + if (value === null || value === undefined) return ''; + let out = ''; + for (const ch of String(value).normalize('NFC')) { + if (ch === '\n' || ch === '\t') out += ch; + else if (ch === '\r') continue; + else if (WIN_1252.has(ch)) out += ch; + else if (/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\ufeff]/.test(ch)) continue; + else out += '?'; + } + return out; +} + +function formatMinutes(min) { + if (min === null || min === undefined || min === '') return ''; + const n = Number(min); + if (!Number.isFinite(n)) return ''; + const h = Math.floor(n / 60); + const m = n % 60; + return h ? `${h} h ${String(m).padStart(2, '0')} min` : `${m} min`; +} + +function formatFrDate(value) { + if (!value) return ''; + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleDateString('fr-FR', { timeZone: 'UTC' }); +} + +// Builds the PDF and resolves with a Buffer. +// data: { company, workOrder, client, report, signature (Buffer|null), generatedAt } +function buildServiceReportPdf(data) { + const PDFDocument = require('pdfkit'); + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ size: 'A4', margin: 50, info: { Title: pdfText(`Rapport d'intervention - ${data.workOrder.title}`).replace(/\s+/g, ' ') } }); + const chunks = []; + doc.on('data', c => chunks.push(c)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + try { + const { workOrder, client, report } = data; + const company = pdfText(data.company).trim() || 'TechIBase'; + const left = doc.page.margins.left; + const width = doc.page.width - left - doc.page.margins.right; + + // Header band + doc.rect(0, 0, doc.page.width, 80).fill('#1d4ed8'); + doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(20).text(company, left, 28, { width, height: 24, lineBreak: false, ellipsis: true }); + doc.font('Helvetica').fontSize(10).text("Rapport d'intervention", left, 54, { width }); + doc.fillColor('#000000'); + doc.y = 100; + + const section = (title) => { + doc.moveDown(0.8); + doc.font('Helvetica-Bold').fontSize(12).fillColor('#1d4ed8').text(title, left, doc.y, { width }); + doc.moveTo(left, doc.y + 1).lineTo(left + width, doc.y + 1).strokeColor('#cbd5e1').stroke(); + doc.moveDown(0.4); + doc.fillColor('#000000').font('Helvetica').fontSize(10.5); + }; + const field = (label, value) => { + if (!value) return; + doc.font('Helvetica-Bold').text(`${label} : `, left, doc.y, { continued: true, width }); + doc.font('Helvetica').text(pdfText(value)); + }; + const block = (label, value) => { + if (!value) return; + doc.font('Helvetica-Bold').text(label, left, doc.y, { width }); + doc.font('Helvetica').text(pdfText(value), left, doc.y, { width }); + doc.moveDown(0.5); + }; + + section('Client'); + field('Nom', client.name); + field('Adresse', client.address); + field('Téléphone', client.phone); + field('N° de contrat', client.contract_number); + + section('Bon de service'); + field('Numéro', `#${workOrder.id}`); + field('Titre', workOrder.title); + field('Équipement', workOrder.equipment_name); + field('Technicien', workOrder.assigned_to_name); + field('Échéance', formatFrDate(workOrder.due_date)); + block('Description', workOrder.description); + + section("Rapport d'intervention"); + block('Travaux effectués', report.work_done); + block('Pièces utilisées', report.parts_used); + field('Temps passé', formatMinutes(report.time_spent_minutes)); + if (report.time_spent_minutes) doc.moveDown(0.5); + block('Observations', report.observations); + + // Signature block must not split across pages. + if (doc.y > doc.page.height - 230) doc.addPage(); + section('Signature du client'); + if (data.signature) { + doc.image(data.signature, left, doc.y, { fit: [260, 100] }); + doc.y += 106; + doc.font('Helvetica').fontSize(10); + field('Signataire', report.signer_name); + field('Signé le', formatFrDate(report.signed_at)); + } else { + doc.font('Helvetica-Oblique').text('Non signé', left, doc.y, { width }); + } + + doc.font('Helvetica').fontSize(8).fillColor('#6b7280') + .text(`Document généré le ${formatFrDate(data.generatedAt || new Date())}`, left, doc.page.height - 60, { width, align: 'right' }); + doc.end(); + } catch (err) { + reject(err); + } + }); +} + +module.exports = { + pdfText, + SIGNATURE_MAX_BYTES, + SignatureError, + parseSignatureDataUrl, + buildServiceReportPdf, + formatMinutes, +}; diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js index 2b08068..0b6a33e 100644 --- a/backend/src/routes/documents.js +++ b/backend/src/routes/documents.js @@ -153,4 +153,7 @@ router.delete('/clients/:clientId/documents/:id', authenticate, async (req, res) } }); +// Shared with the service-report route, which stores generated PDFs the same way. +router.uploadsDir = uploadsDir; + module.exports = router; diff --git a/backend/src/routes/serviceReports.js b/backend/src/routes/serviceReports.js new file mode 100644 index 0000000..8e11cb7 --- /dev/null +++ b/backend/src/routes/serviceReports.js @@ -0,0 +1,245 @@ +const express = require('express'); +const router = express.Router(); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const { authenticate } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); +const { serverError } = require('../lib/respond'); +const { contentDisposition } = require('../lib/fileTypes'); +const { SignatureError, parseSignatureDataUrl, buildServiceReportPdf } = require('../lib/serviceReport'); +const documentsRouter = require('./documents'); + +const isNetlify = !!process.env.NETLIFY; + +const REPORT_COLUMNS = `id, work_order_id, work_done, parts_used, time_spent_minutes, observations, + signer_name, signed_at, document_id, created_by, created_at, updated_at, (signature IS NOT NULL) AS has_signature`; + +// A work order's report is visible to admins and to the users attached to the +// work order (assignee or creator) — not to every authenticated user. +function canAccess(user, workOrder) { + if (user?.role === 'admin') return true; + const uid = String(user?.id); + return [workOrder.assigned_to, workOrder.created_by].some(id => id !== null && id !== undefined && String(id) === uid); +} + +// Loads the work order and enforces access; sends the error response itself +// and returns null when the caller must stop. +async function loadAuthorized(req, res) { + const db = req.app.locals.db; + const result = await db.query( + `SELECT wo.*, c.name AS client_name, c.address AS client_address, c.phone AS client_phone, + c.contract_number AS client_contract, e.name AS equipment_name, u.name AS assigned_to_name + FROM work_orders wo + JOIN clients c ON c.id = wo.client_id + LEFT JOIN equipment e ON e.id = wo.equipment_id + LEFT JOIN users u ON u.id = wo.assigned_to + WHERE wo.id = $1`, + [req.params.id] + ); + if (result.rows.length === 0) { + res.status(404).json({ error: 'Bon de service non trouvé' }); + return null; + } + const workOrder = result.rows[0]; + if (!canAccess(req.user, workOrder)) { + res.status(403).json({ error: 'Accès refusé' }); + return null; + } + return workOrder; +} + +function pdfUnavailable(res) { + return res.status(501).json({ error: "La génération de PDF n'est pas disponible sur ce type de déploiement" }); +} + +function pdfFilename(workOrder) { + return `rapport-intervention-${workOrder.id}.pdf`; +} + +async function renderPdf(db, workOrder, report) { + const signatureRow = await db.query('SELECT signature FROM service_reports WHERE id = $1', [report.id]); + return buildServiceReportPdf({ + company: process.env.COMPANY_NAME || 'TechIBase', + workOrder, + client: { + name: workOrder.client_name, + address: workOrder.client_address, + phone: workOrder.client_phone, + contract_number: workOrder.client_contract, + }, + report, + signature: signatureRow.rows[0]?.signature || null, + generatedAt: new Date(), + }); +} + +router.get('/work-orders/:id/service-report', authenticate, async (req, res) => { + const db = req.app.locals.db; + try { + if (!(await loadAuthorized(req, res))) return; + const result = await db.query(`SELECT ${REPORT_COLUMNS} FROM service_reports WHERE work_order_id = $1`, [req.params.id]); + res.json(result.rows[0] || null); + } catch (err) { + serverError(res, err); + } +}); + +router.get('/work-orders/:id/service-report/signature', authenticate, async (req, res) => { + const db = req.app.locals.db; + try { + if (!(await loadAuthorized(req, res))) return; + const result = await db.query('SELECT signature FROM service_reports WHERE work_order_id = $1', [req.params.id]); + const sig = result.rows[0]?.signature; + if (!sig) return res.status(404).json({ error: 'Signature non trouvée' }); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.send(sig); + } catch (err) { + serverError(res, err); + } +}); + +// PUT creates or updates the report. `signature` (PNG data URL) signs it. +router.put('/work-orders/:id/service-report', authenticate, validate({ + work_done: { type: 'string', maxLength: 10000 }, + parts_used: { type: 'string', maxLength: 5000 }, + time_spent_minutes: { type: 'number' }, + observations: { type: 'string', maxLength: 10000 }, + signer_name: { type: 'string', maxLength: 255 }, + signature: { type: 'string' }, +}), async (req, res) => { + const db = req.app.locals.db; + try { + const workOrder = await loadAuthorized(req, res); + if (!workOrder) return; + const { work_done, parts_used, observations, signer_name, signature } = req.body; + + let time = null; + if (req.body.time_spent_minutes !== undefined && req.body.time_spent_minutes !== null && req.body.time_spent_minutes !== '') { + time = Number(req.body.time_spent_minutes); + if (!Number.isInteger(time) || time < 0 || time > 24 * 60 * 31) { + return res.status(400).json({ error: 'time_spent_minutes doit être un nombre entier de minutes valide' }); + } + } + + let signatureBuf = null; + if (signature) { + if (!signer_name || !String(signer_name).trim()) { + return res.status(400).json({ error: 'Le nom du signataire est requis' }); + } + try { + signatureBuf = parseSignatureDataUrl(signature); + } catch (err) { + if (err instanceof SignatureError) return res.status(400).json({ error: err.message }); + throw err; + } + } + + // The signature covers the report's content, so a save without a fresh + // signature drops any previous one. + const result = await db.query( + `INSERT INTO service_reports (work_order_id, work_done, parts_used, time_spent_minutes, observations, + signer_name, signature, signed_at, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT (work_order_id) DO UPDATE SET + work_done = EXCLUDED.work_done, + parts_used = EXCLUDED.parts_used, + time_spent_minutes = EXCLUDED.time_spent_minutes, + observations = EXCLUDED.observations, + signer_name = EXCLUDED.signer_name, + signature = EXCLUDED.signature, + signed_at = EXCLUDED.signed_at, + updated_at = NOW() + RETURNING ${REPORT_COLUMNS}`, + [ + req.params.id, work_done || null, parts_used || null, time, observations || null, + signatureBuf ? String(signer_name).trim() : null, + signatureBuf, signatureBuf ? new Date() : null, req.user.id, + ] + ); + res.json(result.rows[0]); + } catch (err) { + serverError(res, err); + } +}); + +// GET the PDF, generated on the fly from the current report. +router.get('/work-orders/:id/service-report/pdf', authenticate, async (req, res) => { + const db = req.app.locals.db; + try { + if (isNetlify) return pdfUnavailable(res); + const workOrder = await loadAuthorized(req, res); + if (!workOrder) return; + const result = await db.query(`SELECT ${REPORT_COLUMNS} FROM service_reports WHERE work_order_id = $1`, [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: "Rapport d'intervention non trouvé" }); + const pdf = await renderPdf(db, workOrder, result.rows[0]); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', contentDisposition('attachment', pdfFilename(workOrder))); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.send(pdf); + } catch (err) { + serverError(res, err); + } +}); + +// POST attaches the signed report's PDF to the client's documents. Re-attaching +// replaces the previous attachment instead of piling up copies. +router.post('/work-orders/:id/service-report/attach', authenticate, async (req, res) => { + const db = req.app.locals.db; + try { + if (isNetlify) return pdfUnavailable(res); + const workOrder = await loadAuthorized(req, res); + if (!workOrder) return; + const result = await db.query(`SELECT ${REPORT_COLUMNS} FROM service_reports WHERE work_order_id = $1`, [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: "Rapport d'intervention non trouvé" }); + const report = result.rows[0]; + if (!report.has_signature) return res.status(400).json({ error: 'Le rapport doit être signé avant d\'être joint au dossier du client' }); + + const pdf = await renderPdf(db, workOrder, report); + const filename = `${crypto.randomUUID()}.pdf`; + const uploadsDir = documentsRouter.uploadsDir; + const filePath = path.join(uploadsDir, filename); + await fs.promises.writeFile(filePath, pdf).catch(async err => { + await fs.promises.unlink(filePath).catch(() => {}); // a partial write must not linger + throw err; + }); + + let doc; + let oldDocumentId = null; + try { + doc = await db.query( + 'INSERT INTO documents (client_id, filename, original_name, mimetype, size, uploaded_by) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *', + [workOrder.client_id, filename, pdfFilename(workOrder), 'application/pdf', pdf.length, req.user.id] + ); + // Swap the attachment atomically: the row lock makes concurrent attaches + // each see (and so clean up) exactly the document the previous one set. + const swap = await db.query( + `WITH old AS (SELECT document_id FROM service_reports WHERE id = $2 FOR UPDATE) + UPDATE service_reports SET document_id = $1, updated_at = NOW() + FROM old WHERE service_reports.id = $2 + RETURNING old.document_id AS old_document_id`, + [doc.rows[0].id, report.id] + ); + oldDocumentId = swap.rows[0]?.old_document_id ?? null; + } catch (err) { + // Do not leave the new file (or its row) behind when the swap failed. + if (doc) await db.query('DELETE FROM documents WHERE id = $1', [doc.rows[0].id]).catch(() => {}); + await fs.promises.unlink(filePath).catch(() => {}); + throw err; + } + + if (oldDocumentId) { + const old = await db.query('DELETE FROM documents WHERE id = $1 AND client_id = $2 RETURNING filename', [oldDocumentId, workOrder.client_id]); + if (old.rows[0]) { + await fs.promises.unlink(path.join(uploadsDir, old.rows[0].filename)).catch(() => {}); + } + } + res.status(201).json(doc.rows[0]); + } catch (err) { + serverError(res, err); + } +}); + +module.exports = router; +module.exports.canAccess = canAccess; diff --git a/backend/test/service-reports.test.js b/backend/test/service-reports.test.js new file mode 100644 index 0000000..2b31726 --- /dev/null +++ b/backend/test/service-reports.test.js @@ -0,0 +1,224 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const zlib = require('zlib'); +const request = require('supertest'); +const { skipWithoutPg, createTestDb } = require('./helpers/pg'); + +const uploadsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'techbase-sr-')); +process.env.JWT_SECRET = 'test-secret'; +process.env.UPLOADS_DIR = uploadsDir; +delete process.env.NETLIFY; + +const { app } = require('../src/app'); +const { issueToken } = require('../src/lib/token'); +const { parseSignatureDataUrl, buildServiceReportPdf, SignatureError, formatMinutes, pdfText } = require('../src/lib/serviceReport'); + +// Valid grayscale PNG of any size, built with zlib so the CRCs are right. +function crc32(buf) { + let c; + let crc = 0xffffffff; + for (let n = 0; n < buf.length; n++) { + c = (crc ^ buf[n]) & 0xff; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + crc = (crc >>> 8) ^ c; + } + return (crc ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} +function makePng(w = 1, h = 1) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; + ihdr[9] = 0; + const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(w, 255)]); + const idat = zlib.deflateSync(Buffer.concat(Array.from({ length: h }, () => row))); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0)), + ]); +} +const dataUrl = buf => `data:image/png;base64,${buf.toString('base64')}`; +const PNG_URL = dataUrl(makePng(40, 20)); + +const tokenFor = (id, role = 'user') => ({ Authorization: `Bearer ${issueToken({ id, email: `u${id}@x.fr`, name: `U${id}`, role })}` }); +const binary = (r, cb) => { const c = []; r.on('data', d => c.push(d)); r.on('end', () => cb(null, Buffer.concat(c))); }; + +test('parseSignatureDataUrl accepts a real PNG and rejects everything else', () => { + assert.ok(parseSignatureDataUrl(PNG_URL).length > 8); + assert.throws(() => parseSignatureDataUrl('data:image/jpeg;base64,/9j/4AAQ'), SignatureError); + assert.throws(() => parseSignatureDataUrl('data:image/png;base64,' + Buffer.from('').toString('base64')), SignatureError); + assert.throws(() => parseSignatureDataUrl(dataUrl(makePng(5000, 10))), SignatureError); + assert.throws(() => parseSignatureDataUrl('data:image/png;base64,' + 'A'.repeat(200000)), SignatureError); + assert.throws(() => parseSignatureDataUrl(42), SignatureError); +}); + +test('parseSignatureDataUrl refuses bombs, corrupt streams and polyglots', () => { + const png = makePng(40, 20); + // IHDR declares a big image backed by almost no data (decompression bomb) + const bomb = Buffer.from(png); + bomb.writeUInt32BE(2000, 16); + bomb.writeUInt32BE(2000, 20); + assert.throws(() => parseSignatureDataUrl(dataUrl(bomb)), SignatureError); + // bytes after IEND (polyglot) + assert.throws(() => parseSignatureDataUrl(dataUrl(Buffer.concat([png, Buffer.from(' parseSignatureDataUrl(dataUrl(png.subarray(0, png.length - 20))), SignatureError); + // IDAT that is not a zlib stream + const junk = Buffer.concat([png.subarray(0, 8), chunk('IHDR', png.subarray(16, 29)), chunk('IDAT', Buffer.from('not zlib')), chunk('IEND', Buffer.alloc(0))]); + assert.throws(() => parseSignatureDataUrl(dataUrl(junk)), SignatureError); +}); + +test('pdfText keeps French text and replaces what Helvetica cannot draw', () => { + assert.equal(pdfText('é è à œ € ç « » Œ'), 'é è à œ € ç « » Œ'); + assert.equal(pdfText('a\r\nb\u0000c'), 'a\nbc'); + assert.equal(pdfText('日本 Привет 😀'), '?? ?????? ?'); + assert.equal(pdfText('e\u0301'), 'é'); + assert.equal(pdfText(null), ''); +}); + +test('formatMinutes', () => { + assert.equal(formatMinutes(45), '45 min'); + assert.equal(formatMinutes(125), '2 h 05 min'); + assert.equal(formatMinutes(null), ''); +}); + +test('buildServiceReportPdf returns a valid PDF buffer, signed or not', async () => { + const base = { + company: 'TechIBase', + workOrder: { id: 7, title: 'Révision é à ü', description: 'x'.repeat(3000), equipment_name: 'Pompe', assigned_to_name: 'Jean', due_date: '2026-11-01' }, + client: { name: 'Acme', address: '1 rue', phone: '0102', contract_number: 'C-1' }, + report: { work_done: 'Remplacement joint\nTest', parts_used: 'Joint', time_spent_minutes: 90, observations: 'RAS', signer_name: 'Mme Client', signed_at: new Date() }, + }; + const signed = await buildServiceReportPdf({ ...base, signature: makePng(40, 20) }); + assert.equal(signed.subarray(0, 5).toString(), '%PDF-'); + assert.ok(signed.subarray(-10).toString().includes('%%EOF')); + const unsigned = await buildServiceReportPdf({ ...base, signature: null }); + assert.equal(unsigned.subarray(0, 5).toString(), '%PDF-'); +}); + +test('buildServiceReportPdf copes with a very long report and an absurd company name', async () => { + const pdf = await buildServiceReportPdf({ + company: 'Société '.repeat(40), + workOrder: { id: 1, title: '日本語 😀 titre', description: null }, + client: { name: 'Acme' }, + report: { work_done: 'ligne é\n'.repeat(900), observations: 'x'.repeat(9000), signer_name: 'M', signed_at: new Date() }, + signature: makePng(40, 20), + }); + assert.equal(pdf.subarray(0, 5).toString(), '%PDF-'); + assert.ok((pdf.toString('latin1').match(/\/Type \/Page\b/g) || []).length > 3); +}); + +let testDb; +let woId; + +test.before(async () => { + if (skipWithoutPg.skip) return; + testDb = await createTestDb(); + app.locals.db = testDb.pool; + const p = testDb.pool; + for (const id of [1, 2, 3]) { + await p.query('INSERT INTO users (id, email, name, role) VALUES ($1,$2,$3,$4)', [id, `u${id}@x.fr`, `U${id}`, id === 3 ? 'admin' : 'user']); + } + const clientId = (await p.query("INSERT INTO clients (name, address) VALUES ('Acme','1 rue') RETURNING id")).rows[0].id; + woId = (await p.query("INSERT INTO work_orders (client_id, title, assigned_to, created_by) VALUES ($1,'Révision',1,3) RETURNING id", [clientId])).rows[0].id; +}); + +test.after(async () => { + if (testDb) await testDb.drop(); + fs.rmSync(uploadsDir, { recursive: true, force: true }); +}); + +const url = (id, suffix = '') => `/api/work-orders/${id}/service-report${suffix}`; + +test("only the assignee, the creator and admins can reach a work order's report", skipWithoutPg, async () => { + assert.equal((await request(app).get(url(woId))).status, 401); + assert.equal((await request(app).get(url(woId)).set(tokenFor(2))).status, 403); + assert.equal((await request(app).put(url(woId)).set(tokenFor(2)).send({ work_done: 'x' })).status, 403); + assert.equal((await request(app).get(url(woId, '/pdf')).set(tokenFor(2))).status, 403); + assert.equal((await request(app).get(url(woId)).set(tokenFor(1))).status, 200); + assert.equal((await request(app).get(url(woId)).set(tokenFor(3, 'admin'))).status, 200); + assert.equal((await request(app).get(url(99999)).set(tokenFor(3, 'admin'))).status, 404); +}); + +test('create, sign, download and attach a report', skipWithoutPg, async () => { + const t = tokenFor(1); + assert.equal((await request(app).get(url(woId)).set(t)).body, null); + assert.equal((await request(app).get(url(woId, '/pdf')).set(t)).status, 404); + + const saved = await request(app).put(url(woId)).set(t).send({ work_done: 'Joint remplacé', parts_used: 'Joint', time_spent_minutes: 90, observations: 'RAS' }); + assert.equal(saved.status, 200); + assert.equal(saved.body.has_signature, false); + assert.equal(saved.body.signature, undefined); + + assert.equal((await request(app).post(url(woId, '/attach')).set(t)).status, 400); // unsigned + + assert.equal((await request(app).put(url(woId)).set(t).send({ work_done: 'a', signature: PNG_URL })).status, 400); // no signer + const html = 'data:image/png;base64,' + Buffer.from('').toString('base64'); + assert.equal((await request(app).put(url(woId)).set(t).send({ work_done: 'a', signer_name: 'M', signature: html })).status, 400); + assert.equal((await request(app).put(url(woId)).set(t).send({ work_done: 'a', signer_name: 'M', signature: dataUrl(makePng(5000, 5)) })).status, 400); + assert.equal((await request(app).put(url(woId)).set(t).send({ time_spent_minutes: -3 })).status, 400); + + const signed = await request(app).put(url(woId)).set(t).send({ work_done: 'Joint remplacé', time_spent_minutes: 90, signer_name: 'Mme Client', signature: PNG_URL }); + assert.equal(signed.status, 200); + assert.equal(signed.body.has_signature, true); + assert.ok(signed.body.signed_at); + + const sig = await request(app).get(url(woId, '/signature')).set(t).buffer(true).parse(binary); + assert.equal(sig.status, 200); + assert.equal(sig.headers['content-type'], 'image/png'); + assert.equal(sig.body[1], 0x50); + + const pdf = await request(app).get(url(woId, '/pdf')).set(t).buffer(true).parse(binary); + assert.equal(pdf.status, 200); + assert.equal(pdf.headers['content-type'], 'application/pdf'); + assert.equal(pdf.body.subarray(0, 5).toString(), '%PDF-'); + + const att = await request(app).post(url(woId, '/attach')).set(t); + assert.equal(att.status, 201); + assert.equal(att.body.mimetype, 'application/pdf'); + const firstFile = path.join(uploadsDir, att.body.filename); + assert.equal(fs.readFileSync(firstFile).subarray(0, 5).toString(), '%PDF-'); + const dl = await request(app).get(`/api/clients/${att.body.client_id}/documents/${att.body.id}/download`).set(t); + assert.equal(dl.status, 200); + assert.equal(dl.headers['content-type'], 'application/pdf'); + + const again = await request(app).post(url(woId, '/attach')).set(t); + assert.equal(again.status, 201); + assert.equal(fs.existsSync(firstFile), false); + const docs = await testDb.pool.query('SELECT id FROM documents WHERE client_id = $1', [att.body.client_id]); + assert.equal(docs.rows.length, 1); + + const edited = await request(app).put(url(woId)).set(t).send({ work_done: 'Modifié' }); + assert.equal(edited.body.has_signature, false); + assert.equal((await request(app).get(url(woId, '/signature')).set(t)).status, 404); +}); + +test('concurrent attaches keep exactly one document and leave no orphan file', skipWithoutPg, async () => { + const t = tokenFor(1); + const clientId = (await testDb.pool.query("INSERT INTO clients (name) VALUES ('Beta') RETURNING id")).rows[0].id; + const id = (await testDb.pool.query("INSERT INTO work_orders (client_id, title, assigned_to, created_by) VALUES ($1,'T',1,3) RETURNING id", [clientId])).rows[0].id; + await request(app).put(url(id)).set(t).send({ work_done: 'ok', signer_name: 'M', signature: PNG_URL }); + + const results = await Promise.all([1, 2, 3, 4].map(() => request(app).post(url(id, '/attach')).set(t))); + assert.ok(results.every(r => r.status === 201)); + const docs = await testDb.pool.query('SELECT id, filename FROM documents WHERE client_id = $1', [clientId]); + assert.equal(docs.rows.length, 1); + const link = await testDb.pool.query('SELECT document_id FROM service_reports WHERE work_order_id = $1', [id]); + assert.equal(link.rows[0].document_id, docs.rows[0].id); + const onDisk = fs.readdirSync(uploadsDir).filter(f => f.endsWith('.pdf')); + assert.ok(onDisk.includes(docs.rows[0].filename)); + // every other generated PDF was cleaned up (only the first test's attachment remains besides this one) + const known = (await testDb.pool.query('SELECT filename FROM documents')).rows.map(r => r.filename); + assert.deepEqual(onDisk.filter(f => !known.includes(f)), []); +}); diff --git a/frontend/src/components/ServiceReportModal.jsx b/frontend/src/components/ServiceReportModal.jsx new file mode 100644 index 0000000..781fbea --- /dev/null +++ b/frontend/src/components/ServiceReportModal.jsx @@ -0,0 +1,190 @@ +import React, { useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import SignaturePad from './SignaturePad'; +import { parseDuration, formatDuration, SIGNATURE_MAX_BYTES, signatureDataUrlBytes } from '../lib/serviceReport.mjs'; + +const inputCls = 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm'; +const labelCls = 'block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1'; + +export default function ServiceReportModal({ workOrder, onClose }) { + const [form, setForm] = useState({ work_done: '', parts_used: '', duration: '', observations: '', signer_name: '' }); + const [report, setReport] = useState(null); + const [loaded, setLoaded] = useState(false); + const [padEmpty, setPadEmpty] = useState(true); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [info, setInfo] = useState(''); + const pad = useRef(null); + + useEffect(() => { + axios.get(`/api/work-orders/${workOrder.id}/service-report`) + .then(res => { + const r = res.data; + setReport(r); + if (r) { + setForm({ + work_done: r.work_done || '', + parts_used: r.parts_used || '', + duration: formatDuration(r.time_spent_minutes).replace(' h ', 'h'), + observations: r.observations || '', + signer_name: r.signer_name || '', + }); + } + }) + .catch(err => setError(err.response?.data?.error || 'Erreur de chargement')) + .finally(() => setLoaded(true)); + }, [workOrder.id]); + + const set = (k) => (e) => { setDirty(true); setForm(f => ({ ...f, [k]: e.target.value })); }; + const base = `/api/work-orders/${workOrder.id}/service-report`; + + // Saving drops an existing signature when no new one is drawn (it covers the + // report's content). So when nothing changed since the last load/save, + // "Télécharger" and "Joindre" must reuse the stored report instead of + // re-saving it unsigned. + const save = async () => { + setError(''); + setInfo(''); + if (report && !dirty && padEmpty) return report; + const minutes = parseDuration(form.duration); + if (form.duration.trim() && minutes === null) { + setError('Durée non reconnue (exemples : 1h30, 45 min)'); + return null; + } + const body = { + work_done: form.work_done, + parts_used: form.parts_used, + observations: form.observations, + time_spent_minutes: minutes, + }; + if (!padEmpty) { + const signature = pad.current.toDataURL(); + if (signatureDataUrlBytes(signature) > SIGNATURE_MAX_BYTES) { + setError('Signature trop volumineuse, effacez et recommencez avec un tracé plus simple'); + return null; + } + if (!form.signer_name.trim()) { + setError('Indiquez le nom du signataire'); + return null; + } + body.signature = signature; + body.signer_name = form.signer_name; + } + setBusy(true); + try { + const res = await axios.put(base, body); + setReport(res.data); + setDirty(false); + pad.current?.clear(); + return res.data; + } catch (err) { + setError(err.response?.data?.error || 'Erreur'); + return null; + } finally { + setBusy(false); + } + }; + + const handleSave = async () => { + const r = await save(); + if (r) setInfo(r.has_signature ? 'Rapport enregistré et signé.' : 'Rapport enregistré (non signé).'); + }; + + const handleDownload = async () => { + const r = await save(); + if (!r) return; + try { + const res = await axios.get(`${base}/pdf`, { responseType: 'blob' }); + const url = URL.createObjectURL(res.data); + const a = document.createElement('a'); + a.href = url; + a.download = `rapport-intervention-${workOrder.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + setError(err.response?.status === 501 ? "La génération de PDF n'est pas disponible sur ce déploiement" : 'Erreur lors de la génération du PDF'); + } + }; + + const handleAttach = async () => { + const r = await save(); + if (!r) return; + if (!r.has_signature) { + setError('Le rapport doit être signé avant d\'être joint au dossier du client'); + return; + } + setBusy(true); + try { + await axios.post(`${base}/attach`); + setInfo('PDF joint aux documents du client.'); + } catch (err) { + setError(err.response?.data?.error || 'Erreur'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+
+

Rapport d'intervention

+

{workOrder.title} · {workOrder.client_name}

+
+ +
+ +
+ {error &&

{error}

} + {info &&

{info}

} + {!loaded &&

Chargement…

} + +
+ +