From 4dc62c522ef99824d287a8f12687c5fdf8696909 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 21:41:07 +0000 Subject: [PATCH 1/5] Add signed service reports with PDF generation Technicians fill a service report on a work order, the customer signs on a canvas pad (PNG, 64 kB cap, magic-byte and dimension checks), and a PDF is generated with pdfkit, downloadable or attached to the client's documents. Access is limited to admins, the assignee and the creator of the work order. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01StC4huMbM6BRB9r4XFXvXv --- CHANGELOG.md | 2 + backend/db/init.sql | 16 + backend/package-lock.json | 828 ++++++++++++++++++ backend/package.json | 1 + backend/src/app.js | 2 + backend/src/db/schema.js | 16 + backend/src/lib/serviceReport.js | 141 +++ backend/src/routes/documents.js | 3 + backend/src/routes/serviceReports.js | 223 +++++ backend/test/service-reports.test.js | 169 ++++ .../src/components/ServiceReportModal.jsx | 183 ++++ frontend/src/components/SignaturePad.jsx | 93 ++ frontend/src/lib/serviceReport.mjs | 33 + frontend/src/pages/WorkOrders.jsx | 10 + frontend/test/serviceReport.test.mjs | 33 + package-lock.json | 814 ++++++++++++++++- package.json | 1 + 17 files changed, 2567 insertions(+), 1 deletion(-) create mode 100644 backend/src/lib/serviceReport.js create mode 100644 backend/src/routes/serviceReports.js create mode 100644 backend/test/service-reports.test.js create mode 100644 frontend/src/components/ServiceReportModal.jsx create mode 100644 frontend/src/components/SignaturePad.jsx create mode 100644 frontend/src/lib/serviceReport.mjs create mode 100644 frontend/test/serviceReport.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 020b745..4ec78a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Toutes les versions notables de TechIBase sont documentées ici. Le projet est e ## [Non publié] +- **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) - **La version 0.0.13 n'a pas été publiée** : la construction de l'image de l'interface s'est bloquée sous émulation ; seules trois de ses images existent sur le registre, sans étiquette ni release GitHub. Cette version 0.0.14 contient tout ce que devait contenir la 0.0.13 (mises à jour en un clic, ci-dessous). diff --git a/backend/db/init.sql b/backend/db/init.sql index fdc4b1a..10919fd 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 5aeba7e..f1e08b3 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 adminRouter = require('./routes/admin'); @@ -71,6 +72,7 @@ app.use('/api', logbookRouter); app.use('/api', documentsRouter); app.use('/api', searchRouter); app.use('/api', workOrdersRouter); +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 296a8b3..6453b41 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..9b2646d --- /dev/null +++ b/backend/src/lib/serviceReport.js @@ -0,0 +1,141 @@ +// 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 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 {} + +// 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'); + } + return buf; +} + +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: `Rapport d'intervention - ${data.workOrder.title}` } }); + 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 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(data.company || 'TechIBase', left, 28, { width }); + 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(String(value)); + }; + const block = (label, value) => { + if (!value) return; + doc.font('Helvetica-Bold').text(label, left, doc.y, { width }); + doc.font('Helvetica').text(String(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 = { + 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..c4c8e9b --- /dev/null +++ b/backend/src/routes/serviceReports.js @@ -0,0 +1,223 @@ +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; + await fs.promises.writeFile(path.join(uploadsDir, filename), pdf); + + const 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] + ); + + if (report.document_id) { + const old = await db.query('DELETE FROM documents WHERE id = $1 AND client_id = $2 RETURNING filename', [report.document_id, workOrder.client_id]); + if (old.rows[0]) { + await fs.promises.unlink(path.join(uploadsDir, old.rows[0].filename)).catch(() => {}); + } + } + await db.query('UPDATE service_reports SET document_id = $1, updated_at = NOW() WHERE id = $2', [doc.rows[0].id, report.id]); + 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..b9a5dc3 --- /dev/null +++ b/backend/test/service-reports.test.js @@ -0,0 +1,169 @@ +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 } = 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('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-'); +}); + +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); +}); diff --git a/frontend/src/components/ServiceReportModal.jsx b/frontend/src/components/ServiceReportModal.jsx new file mode 100644 index 0000000..6e7d80a --- /dev/null +++ b/frontend/src/components/ServiceReportModal.jsx @@ -0,0 +1,183 @@ +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 [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) => setForm(f => ({ ...f, [k]: e.target.value })); + const base = `/api/work-orders/${workOrder.id}/service-report`; + + const save = async () => { + setError(''); + setInfo(''); + 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); + 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…

} + +
+ +