From 82a6bc20f9da06fe4dcaab347a1181dbe6d25f95 Mon Sep 17 00:00:00 2001 From: odweta Date: Thu, 10 Sep 2026 15:53:08 +0200 Subject: [PATCH] v2 --- README.md | 19 +- app/Dockerfile | 6 +- app/docker-compose.yml | 30 +- app/package-lock.json | 802 ++++++++++++++++++++++++++++++++++++-- app/package.json | 5 +- app/src/app.js | 616 +++++++++++++++++++++++++++++ app/src/db.js | 92 +++++ app/src/invoice-data.js | 28 ++ app/src/server.js | 12 +- app/src/static/index.html | 224 ++++++++++- app/src/static/script.js | 656 ++++++++++++++++++++++++++++++- app/src/static/style.css | 640 +++++++++++++++++++++++++++++- app/test/app.test.js | 75 ++-- 13 files changed, 3090 insertions(+), 115 deletions(-) create mode 100644 app/src/db.js create mode 100644 app/src/invoice-data.js diff --git a/README.md b/README.md index d8b6f45..402dc56 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,22 @@ # Fakturovac -A simple app for creating and managing invoices +A simple app for creating and managing invoices. + +The app uses PostgreSQL for accounts, sessions, supplier profiles, and clients. +Supplier and client data is private to the signed-in account. To deploy, run: ``` -make rebuild -``` \ No newline at end of file +make deploy +``` + +Or start the stack directly from this directory: + +```bash +docker compose up --build +``` + +Then open `http://localhost:3001` and create an account. PostgreSQL data is kept +in the `postgres-data` Docker volume, so it survives app and container restarts. +To remove the database as well, run `docker compose down -v`. \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile index 3afc81d..0d30c1f 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,4 +1,4 @@ -FROM node:latest +FROM node:22-bookworm-slim WORKDIR /app @@ -6,6 +6,10 @@ COPY package.json package-lock.json ./ RUN npm ci +RUN apt-get update \ + && apt-get install -y --no-install-recommends fonts-dejavu \ + && rm -rf /var/lib/apt/lists/* + COPY . . CMD ["npm", "run", "serve"] diff --git a/app/docker-compose.yml b/app/docker-compose.yml index fe12cb7..e2a82d9 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -1,6 +1,34 @@ name: fakturovac services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: fakturovac + POSTGRES_USER: fakturovac + POSTGRES_PASSWORD: fakturovac + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fakturovac -d fakturovac"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - postgres-data:/var/lib/postgresql/data app: build: . + environment: + DATABASE_URL: postgres://fakturovac:fakturovac@db:5432/fakturovac + NODE_ENV: production + SESSION_COOKIE_SECURE: "false" + depends_on: + db: + condition: service_healthy ports: - - "3001:3001" \ No newline at end of file + - "3001:3001" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3001/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres-data: \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index 82d442e..40a359e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,9 +8,12 @@ "name": "Fakturovac", "version": "1.0.0", "dependencies": { + "archiver": "^8.0.0", "express": "5.2.1", "node-fetch": "3.3.2", - "pg": "^8.11.3" + "pdfkit": "^0.20.2", + "pg": "^8.11.3", + "qrcode": "^1.5.4" }, "devDependencies": { "jest": "30.5.1", @@ -1107,11 +1110,22 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -1486,6 +1500,15 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1953,6 +1976,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1986,7 +2021,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1996,7 +2030,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2035,6 +2068,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/archiver/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -2052,6 +2117,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2059,6 +2130,20 @@ "dev": true, "license": "MIT" }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "30.5.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.1.tgz", @@ -2162,12 +2247,111 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" } }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "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/baseline-browser-mapping": { "version": "2.11.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", @@ -2222,7 +2406,6 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2231,6 +2414,15 @@ "node": "20 || >=22" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.9", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", @@ -2275,6 +2467,39 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2327,7 +2552,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2419,6 +2643,15 @@ "node": ">=12" } }, + "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/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2441,7 +2674,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2454,7 +2686,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2480,6 +2711,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/compress-commons/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2534,6 +2793,37 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2575,6 +2865,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -2650,6 +2949,18 @@ "wrappy": "1" } }, + "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/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2701,7 +3012,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -2825,6 +3135,33 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -2920,6 +3257,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2985,6 +3334,12 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3010,7 +3365,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -3020,6 +3374,23 @@ "node": ">=8" } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3161,7 +3532,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -3367,6 +3737,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -3433,7 +3823,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3481,6 +3870,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4216,6 +4611,48 @@ "node": ">=6" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -4226,6 +4663,25 @@ "node": ">=6" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4237,7 +4693,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -4388,7 +4843,6 @@ "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.8" @@ -4534,7 +4988,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4622,7 +5075,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -4635,7 +5087,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -4651,7 +5102,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4664,6 +5114,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -4696,7 +5152,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4749,6 +5204,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdfkit": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz", + "integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0", + "fflate": "^0.8.3", + "fontkit": "^2.0.4", + "linebreak": "^1.1.0", + "png-js": "^2.0.0" + } + }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", @@ -4881,6 +5350,23 @@ "node": ">=8" } }, + "node_modules/png-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", + "integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==", + "dependencies": { + "fflate": "^0.8.2" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -4949,6 +5435,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4979,6 +5480,89 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -5023,16 +5607,52 @@ "node": ">= 0.10" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -5056,6 +5676,12 @@ "node": ">=8" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -5072,6 +5698,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5133,6 +5779,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5289,6 +5941,26 @@ "node": ">= 0.8" } }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -5307,7 +5979,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -5338,7 +6009,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -5459,6 +6129,27 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", @@ -5553,6 +6244,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "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", @@ -5566,9 +6272,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-detect": { "version": "4.0.8", @@ -5631,6 +6335,26 @@ "dev": true, "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/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5709,6 +6433,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -5758,6 +6488,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -5895,6 +6631,20 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "license": "MIT", + "dependencies": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } } } } diff --git a/app/package.json b/app/package.json index 1563d25..13ea209 100644 --- a/app/package.json +++ b/app/package.json @@ -10,9 +10,12 @@ "serve": "node src/server.js" }, "dependencies": { + "archiver": "^8.0.0", "express": "5.2.1", "node-fetch": "3.3.2", - "pg": "^8.11.3" + "pdfkit": "^0.20.2", + "pg": "^8.11.3", + "qrcode": "^1.5.4" }, "devDependencies": { "jest": "30.5.1", diff --git a/app/src/app.js b/app/src/app.js index b2d607c..a7f03fa 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -1,7 +1,618 @@ const express = require('express'); const path = require('path'); +const crypto = require('crypto'); +const archiver = require('archiver'); +const PDFDocument = require('pdfkit'); +const QRCode = require('qrcode'); +const { pool, initializeDatabase } = require('./db'); +const { normalizeInvoiceData } = require('./invoice-data'); const app = express(); +const sessionDurationMs = 1000 * 60 * 60 * 24 * 30; + +app.use(express.json()); + +const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => new Promise((resolve, reject) => { + crypto.scrypt(password, salt, 64, (error, derivedKey) => { + if (error) { + reject(error); + return; + } + resolve(`${salt}:${derivedKey.toString('hex')}`); + }); +}); + +const verifyPassword = async (password, storedHash) => { + const [salt, expectedHex] = storedHash.split(':'); + const actualHash = await hashPassword(password, salt); + const actualBuffer = Buffer.from(actualHash.split(':')[1], 'hex'); + const expectedBuffer = Buffer.from(expectedHex, 'hex'); + return actualBuffer.length === expectedBuffer.length + && crypto.timingSafeEqual(actualBuffer, expectedBuffer); +}; + +const getSessionToken = (req) => { + const cookie = req.headers.cookie || ''; + const sessionCookie = cookie.split(';').map((part) => part.trim()) + .find((part) => part.startsWith('fakturovac_session=')); + return sessionCookie ? decodeURIComponent(sessionCookie.split('=').slice(1).join('=')) : ''; +}; + +const setSessionCookie = (res, token) => { + const secure = process.env.SESSION_COOKIE_SECURE === 'true' ? '; Secure' : ''; + res.setHeader('Set-Cookie', `fakturovac_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionDurationMs / 1000}${secure}`); +}; + +const clearSessionCookie = (res) => { + const secure = process.env.SESSION_COOKIE_SECURE === 'true' ? '; Secure' : ''; + res.setHeader('Set-Cookie', `fakturovac_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`); +}; + +const createSession = async (userId) => { + const token = crypto.randomBytes(32).toString('hex'); + await pool.query( + 'INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'30 days\')', + [token, userId] + ); + return token; +}; + +const requireAuth = async (req, res, next) => { + try { + await initializeDatabase(); + const token = getSessionToken(req); + if (!token) { + res.status(401).json({ error: 'Nejste přihlášeni.' }); + return; + } + + const result = await pool.query( + 'SELECT user_id FROM sessions WHERE id = $1 AND expires_at > NOW()', + [token] + ); + if (!result.rowCount) { + clearSessionCookie(res); + res.status(401).json({ error: 'Relace vypršela.' }); + return; + } + + req.userId = result.rows[0].user_id; + next(); + } catch (error) { + next(error); + } +}; + +const validateCredentials = (email, password) => typeof email === 'string' + && email.includes('@') + && typeof password === 'string' + && password.length >= 8; + +const profileFields = ['nazevSpolecnosti', 'ico', 'jmeno', 'prijmeni', 'uliceCp', 'psc', 'mesto']; +const profileValues = (body) => profileFields.map((field) => typeof body[field] === 'string' ? body[field].trim() : ''); +const profileResponse = (row) => ({ + typSubjektu: row.typ_subjektu === 'spolecnost' ? 'spolecnost' : 'osoba', + nazevSpolecnosti: row.nazev_spolecnosti, + ico: row.ico, + jmeno: row.jmeno, + prijmeni: row.prijmeni, + uliceCp: row.ulice_cp, + psc: row.psc, + mesto: row.mesto +}); + +const clientResponse = (row) => ({ + id: row.id, + typSubjektu: row.typ_subjektu === 'spolecnost' ? 'spolecnost' : 'osoba', + data: profileResponse(row) +}); + + +app.post('/api/auth/register', async (req, res, next) => { + try { + await initializeDatabase(); + const email = String(req.body.email || '').trim().toLowerCase(); + const password = req.body.password; + if (!validateCredentials(email, password)) { + res.status(400).json({ error: 'Zadejte platný e-mail a heslo dlouhé alespoň 8 znaků.' }); + return; + } + + const passwordHash = await hashPassword(password); + const result = await pool.query( + 'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email', + [email, passwordHash] + ); + const token = await createSession(result.rows[0].id); + setSessionCookie(res, token); + res.status(201).json({ user: result.rows[0] }); + } catch (error) { + if (error.code === '23505') { + res.status(409).json({ error: 'Účet s tímto e-mailem už existuje.' }); + return; + } + next(error); + } +}); + +app.post('/api/auth/login', async (req, res, next) => { + try { + await initializeDatabase(); + const email = String(req.body.email || '').trim().toLowerCase(); + const result = await pool.query('SELECT id, email, password_hash FROM users WHERE email = $1', [email]); + if (!result.rowCount || !(await verifyPassword(req.body.password || '', result.rows[0].password_hash))) { + res.status(401).json({ error: 'E-mail nebo heslo není správně.' }); + return; + } + + const token = await createSession(result.rows[0].id); + setSessionCookie(res, token); + res.json({ user: { id: result.rows[0].id, email: result.rows[0].email } }); + } catch (error) { + next(error); + } +}); + +app.post('/api/auth/logout', async (req, res, next) => { + try { + await initializeDatabase(); + const token = getSessionToken(req); + if (token) { + await pool.query('DELETE FROM sessions WHERE id = $1', [token]); + } + clearSessionCookie(res); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +app.get('/api/auth/me', requireAuth, async (req, res, next) => { + try { + const result = await pool.query('SELECT id, email FROM users WHERE id = $1', [req.userId]); + res.json({ user: result.rows[0] }); + } catch (error) { + next(error); + } +}); + +app.post('/api/auth/password', requireAuth, async (req, res, next) => { + try { + const currentPassword = req.body.currentPassword || ''; + const newPassword = req.body.newPassword || ''; + const result = await pool.query('SELECT password_hash FROM users WHERE id = $1', [req.userId]); + if (!result.rowCount || !(await verifyPassword(currentPassword, result.rows[0].password_hash))) { + res.status(400).json({ error: 'Současné heslo není správně.' }); + return; + } + if (!validateCredentials('account@example.com', newPassword)) { + res.status(400).json({ error: 'Nové heslo musí mít alespoň 8 znaků.' }); + return; + } + + await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [await hashPassword(newPassword), req.userId]); + await pool.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [req.userId, getSessionToken(req)]); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +app.get('/api/supplier', requireAuth, async (req, res, next) => { + try { + const result = await pool.query('SELECT * FROM supplier_profiles WHERE user_id = $1', [req.userId]); + res.json({ supplier: result.rowCount ? profileResponse(result.rows[0]) : null }); + } catch (error) { + next(error); + } +}); + +app.put('/api/supplier', requireAuth, async (req, res, next) => { + try { + const values = profileValues(req.body); + const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba'; + const result = await pool.query(` + INSERT INTO supplier_profiles (user_id, typ_subjektu, nazev_spolecnosti, ico, jmeno, prijmeni, ulice_cp, psc, mesto) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (user_id) DO UPDATE SET + typ_subjektu = EXCLUDED.typ_subjektu, nazev_spolecnosti = EXCLUDED.nazev_spolecnosti, + ico = EXCLUDED.ico, + jmeno = EXCLUDED.jmeno, prijmeni = EXCLUDED.prijmeni, + ulice_cp = EXCLUDED.ulice_cp, psc = EXCLUDED.psc, mesto = EXCLUDED.mesto + RETURNING * + `, [req.userId, type, ...values]); + res.json({ supplier: profileResponse(result.rows[0]) }); + } catch (error) { + next(error); + } +}); + +app.get('/api/clients', requireAuth, async (req, res, next) => { + try { + const result = await pool.query('SELECT * FROM clients WHERE user_id = $1 ORDER BY created_at, id', [req.userId]); + res.json({ clients: result.rows.map(clientResponse) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/clients', requireAuth, async (req, res, next) => { + try { + const values = profileValues(req.body); + const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba'; + const result = await pool.query(` + INSERT INTO clients (user_id, typ_subjektu, nazev_spolecnosti, ico, jmeno, prijmeni, ulice_cp, psc, mesto) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING * + `, [req.userId, type, ...values]); + res.status(201).json({ client: clientResponse(result.rows[0]) }); + } catch (error) { + next(error); + } +}); + +app.put('/api/clients/:id', requireAuth, async (req, res, next) => { + try { + const values = profileValues(req.body); + const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba'; + const result = await pool.query(` + UPDATE clients SET typ_subjektu = $1, nazev_spolecnosti = $2, ico = $3, jmeno = $4, + prijmeni = $5, ulice_cp = $6, psc = $7, mesto = $8 + WHERE id = $9 AND user_id = $10 RETURNING * + `, [type, ...values, req.params.id, req.userId]); + if (!result.rowCount) { + res.status(404).json({ error: 'Odběratel nebyl nalezen.' }); + return; + } + res.json({ client: clientResponse(result.rows[0]) }); + } catch (error) { + next(error); + } +}); + +app.delete('/api/clients/:id', requireAuth, async (req, res, next) => { + try { + await pool.query('DELETE FROM clients WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +const paymentPresetResponse = (row) => ({ + id: row.id, + name: row.name, + accountNumber: row.account_number, + iban: row.iban, + swift: row.swift +}); + +app.get('/api/payment-presets', requireAuth, async (req, res, next) => { + try { + const result = await pool.query( + 'SELECT * FROM payment_presets WHERE user_id = $1 ORDER BY name', + [req.userId] + ); + res.json({ presets: result.rows.map(paymentPresetResponse) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/payment-presets', requireAuth, async (req, res, next) => { + try { + const name = String(req.body.name || '').trim(); + if (!name) { + res.status(400).json({ error: 'Zadejte název platební konfigurace.' }); + return; + } + const result = await pool.query(` + INSERT INTO payment_presets (user_id, name, account_number, iban, swift) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, name) DO UPDATE SET + account_number = EXCLUDED.account_number, + iban = EXCLUDED.iban, + swift = EXCLUDED.swift + RETURNING * + `, [ + req.userId, + name, + String(req.body.accountNumber || '').trim(), + String(req.body.iban || '').trim(), + String(req.body.swift || '').trim() + ]); + res.status(201).json({ preset: paymentPresetResponse(result.rows[0]) }); + } catch (error) { + next(error); + } +}); + +app.delete('/api/payment-presets/:id', requireAuth, async (req, res, next) => { + try { + await pool.query('DELETE FROM payment_presets WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +const invoiceResponse = (row) => ({ + id: row.id, + invoiceNumber: row.invoice_number, + data: row.invoice_data, + createdAt: row.created_at, + updatedAt: row.updated_at +}); + +app.get('/api/invoices', requireAuth, async (req, res, next) => { + try { + const result = await pool.query( + 'SELECT id, invoice_number, invoice_data, created_at, updated_at FROM invoices WHERE user_id = $1 ORDER BY updated_at DESC, id DESC', + [req.userId] + ); + res.json({ invoices: result.rows.map(invoiceResponse) }); + } catch (error) { + next(error); + } +}); + +app.get('/health', async (req, res, next) => { + try { + await initializeDatabase(); + await pool.query('SELECT 1'); + res.json({ status: 'ok' }); + } catch (error) { + next(error); + } +}); + +app.post('/api/invoices', requireAuth, async (req, res, next) => { + const client = await pool.connect(); + try { + const data = normalizeInvoiceData(req.body.data); + if (!data) { + res.status(400).json({ error: 'Neplatná data faktury.' }); + return; + } + + const invoiceYear = new Date().getFullYear(); + await client.query('BEGIN'); + await client.query(` + INSERT INTO invoice_counters (user_id, invoice_year, last_number) + SELECT $1, $2, COALESCE(MAX((substring(invoice_number FROM '^F-[0-9]{4}-([0-9]+)$'))::integer), 0) + FROM invoices + WHERE user_id = $1 AND invoice_number LIKE $3 + ON CONFLICT (user_id, invoice_year) DO NOTHING + `, [req.userId, invoiceYear, `F-${invoiceYear}-%`]); + const counterResult = await client.query(` + UPDATE invoice_counters + SET last_number = last_number + 1 + WHERE user_id = $1 AND invoice_year = $2 + RETURNING last_number + `, [req.userId, invoiceYear]); + const invoiceNumber = `F-${invoiceYear}-${String(counterResult.rows[0].last_number).padStart(4, '0')}`; + const result = await client.query( + 'INSERT INTO invoices (user_id, invoice_number, invoice_data) VALUES ($1, $2, $3) RETURNING *', + [req.userId, invoiceNumber, data] + ); + await client.query('COMMIT'); + res.status(201).json({ invoice: invoiceResponse(result.rows[0]) }); + } catch (error) { + await client.query('ROLLBACK'); + next(error); + } finally { + client.release(); + } +}); + +app.put('/api/invoices/:id', requireAuth, async (req, res, next) => { + try { + const data = normalizeInvoiceData(req.body.data); + if (!data) { + res.status(400).json({ error: 'Neplatná data faktury.' }); + return; + } + const result = await pool.query( + 'UPDATE invoices SET invoice_data = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3 RETURNING *', + [data, req.params.id, req.userId] + ); + if (!result.rowCount) { + res.status(404).json({ error: 'Faktura nebyla nalezena.' }); + return; + } + res.json({ invoice: invoiceResponse(result.rows[0]) }); + } catch (error) { + next(error); + } +}); + +app.delete('/api/invoices/:id', requireAuth, async (req, res, next) => { + try { + await pool.query('DELETE FROM invoices WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +app.delete('/api/invoices', requireAuth, async (req, res, next) => { + try { + await pool.query('DELETE FROM invoices WHERE user_id = $1', [req.userId]); + await pool.query('DELETE FROM invoice_counters WHERE user_id = $1', [req.userId]); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +const pdfText = (value) => String(value || 'Neuvedeno'); +const formatCzechNumber = (value) => Number(value || 0).toLocaleString('cs-CZ', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 +}); + +const normalizeIban = (value) => String(value || '').replace(/\s+/g, '').toUpperCase(); + +const domesticAccountToIban = (accountNumber) => { + const match = String(accountNumber || '').replace(/\s+/g, '') + .match(/^(?:(\d{1,6})-)?(\d{1,10})\/(\d{4})$/); + if (!match) { + return ''; + } + const bban = `${match[3]}${(match[1] || '').padStart(6, '0')}${match[2].padStart(10, '0')}`; + const remainder = `${bban}123500`.split('').reduce( + (value, digit) => (value * 10 + Number(digit)) % 97, + 0 + ); + return `CZ${String(98 - remainder).padStart(2, '0')}${bban}`; +}; + +const buildPaymentQrPayload = (payment, total, invoiceNumber = '') => { + const account = normalizeIban(payment.iban) || domesticAccountToIban(payment.cisloUctu); + if (!/^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(account)) { + return ''; + } + return [ + 'SPD*1.0', + `ACC:${account}`, + `AM:${Number(total || 0).toFixed(2)}`, + 'CC:CZK', + invoiceNumber && `X-VS:${invoiceNumber.replace(/\D/g, '').slice(-10)}`, + payment.swift && `X-SWIFT:${normalizeIban(payment.swift)}` + ].filter(Boolean).join('*'); +}; + +const appendInvoicePdf = async (archive, invoice) => { + const data = invoice.invoice_data || {}; + const supplier = data.supplier || {}; + const customer = data.customer || {}; + const payment = data.payment || {}; + const supplierName = supplier.typSubjektu === 'spolecnost' + ? supplier.nazevSpolecnosti + : [supplier.jmeno, supplier.prijmeni].filter(Boolean).join(' '); + const customerName = customer.typSubjektu === 'spolecnost' + ? customer.nazevSpolecnosti + : [customer.jmeno, customer.prijmeni].filter(Boolean).join(' '); + const document = new PDFDocument({ size: 'A4', margin: 50 }); + const regularFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'; + const boldFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'; + const qrPayload = buildPaymentQrPayload(payment, data.total, invoice.invoice_number); + const qrBuffer = qrPayload ? await QRCode.toBuffer(qrPayload, { + type: 'png', + width: 180, + margin: 1 + }) : null; + const pageWidth = document.page.width - document.page.margins.left - document.page.margins.right; + const navy = '#183b56'; + const blue = '#2b6f8f'; + const line = '#d8d4cc'; + const lightBlue = '#f4f7fb'; + const warm = '#fffaf0'; + const bodyText = '#17202a'; + const muted = '#65717d'; + const address = (profile) => [ + profile.uliceCp, + [profile.psc, profile.mesto].filter(Boolean).join(', ') + ].filter(Boolean).join(', '); + const identity = (profile, name) => [ + name || 'Neuvedeno', + profile.ico ? `IČO: ${profile.ico}` : '', + address(profile) + ].filter(Boolean); + const partyWidth = (pageWidth - 24) / 2; + const customerX = document.page.margins.left + partyWidth + 24; + + archive.append(document, { name: `${invoice.invoice_number}.pdf` }); + const headerY = document.y; + document.fillColor(navy).font(boldFont).fontSize(25).text('Faktura', document.page.margins.left, headerY); + document.fillColor(navy).font(boldFont).fontSize(16).text(invoice.invoice_number, customerX, headerY, { + width: partyWidth, + align: 'right' + }); + document.y = headerY + 40; + + const partyTop = document.y; + const drawParty = (x, title, profile, name) => { + const lines = identity(profile, name); + document.roundedRect(x, partyTop, partyWidth, 86, 4).fillAndStroke('#f4f7fb', line); + document.fillColor(navy).font(boldFont).fontSize(11).text(title, x + 10, partyTop + 10, { width: partyWidth - 20 }); + document.fillColor(bodyText).font(regularFont).fontSize(9).text(lines.join('\n'), x + 10, partyTop + 30, { width: partyWidth - 20, lineGap: 3 }); + }; + drawParty(document.page.margins.left, 'Dodavatel', supplier, supplierName); + drawParty(customerX, 'Odběratel', customer, customerName); + document.y = partyTop + 100; + + const tableTop = document.y + 12; + const columns = [0, pageWidth * 0.52, pageWidth * 0.68, pageWidth * 0.82, pageWidth]; + const rowHeight = 25; + document.rect(document.page.margins.left, tableTop, pageWidth, rowHeight).fill('#eef1f3'); + document.fillColor(navy).font(boldFont).fontSize(9); + ['Položka', 'Množství', 'MJ', 'Cena za MJ'].forEach((heading, index) => { + document.text(heading, document.page.margins.left + columns[index] + 6, tableTop + 8, { + width: columns[index + 1] - columns[index] - 12 + }); + }); + document.y = tableTop + rowHeight; + (data.items || []).forEach((item, index) => { + const rowTop = document.y; + document.rect(document.page.margins.left, rowTop, pageWidth, rowHeight) + .fill(index % 2 ? lightBlue : warm); + document.moveTo(document.page.margins.left, rowTop + rowHeight) + .lineTo(document.page.margins.left + pageWidth, rowTop + rowHeight) + .strokeColor(line).lineWidth(0.7).stroke(); + document.fillColor(bodyText).font(regularFont).fontSize(9); + [pdfText(item.popis), formatCzechNumber(item.mnozstvi), pdfText(item.mernaJednotka), `${formatCzechNumber(item.cenaZaMj)} Kč`] + .forEach((value, columnIndex) => document.text(value, document.page.margins.left + columns[columnIndex] + 6, rowTop + 8, { + width: columns[columnIndex + 1] - columns[columnIndex] - 12 + })); + document.y = rowTop + rowHeight; + }); + + document.y += 18; + document.fillColor(navy).font(boldFont).fontSize(15) + .text(`Celková částka: ${formatCzechNumber(data.total)} Kč`, document.page.margins.left, document.y, { + width: pageWidth, + align: 'right' + }); + document.y += 35; + document.moveTo(document.page.margins.left, document.y).lineTo(document.page.margins.left + pageWidth, document.y) + .strokeColor(navy).lineWidth(1.5).stroke(); + document.y += 12; + const paymentTop = document.y; + document.roundedRect(document.page.margins.left, paymentTop, pageWidth, 165, 4).fillAndStroke('#f4f7fb', line); + document.fillColor(navy).font(boldFont).fontSize(11).text('Platební údaje', document.page.margins.left + 10, paymentTop + 10); + document.fillColor(bodyText).font(regularFont).fontSize(10) + .text(`Číslo účtu: ${pdfText(payment.cisloUctu)}`, document.page.margins.left + 10, paymentTop + 35) + .text(`IBAN: ${pdfText(payment.iban)}`, document.page.margins.left + 10, paymentTop + 55) + .text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 75); + if (qrBuffer) { + document.image(qrBuffer, document.page.margins.left + pageWidth - 155, paymentTop + 10, { width: 145, height: 145 }); + } + document.end(); +}; + +app.get('/api/invoices/export', requireAuth, async (req, res, next) => { + try { + const result = await pool.query( + 'SELECT invoice_number, invoice_data FROM invoices WHERE user_id = $1 ORDER BY invoice_number', + [req.userId] + ); + if (!result.rowCount) { + res.status(404).json({ error: 'Nejsou uložené žádné faktury k exportu.' }); + return; + } + + res.attachment('faktury.zip'); + const archive = new archiver.ZipArchive({ zlib: { level: 9 } }); + archive.on('error', next); + archive.pipe(res); + for (const invoice of result.rows) { + await appendInvoicePdf(archive, invoice); + } + archive.finalize(); + } catch (error) { + next(error); + } +}); // specifying the folder with the static content - html+css app.use(express.static(__dirname + '/static')); @@ -10,4 +621,9 @@ app.get('/', (req, res) => { res.sendFile(path.join(__dirname, '/static/index.html')); }); +app.use((error, req, res, next) => { + console.error(error); + res.status(500).json({ error: 'Na serveru došlo k chybě.' }); +}); + module.exports = app; \ No newline at end of file diff --git a/app/src/db.js b/app/src/db.js new file mode 100644 index 0000000..f1c8edb --- /dev/null +++ b/app/src/db.js @@ -0,0 +1,92 @@ +const { Pool } = require("pg"); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL || "postgres://fakturovac:fakturovac@localhost:5432/fakturovac" +}); + +let schemaPromise; + +const initializeDatabase = () => { + if (!schemaPromise) { + schemaPromise = pool.query(` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL + ); + + CREATE TABLE IF NOT EXISTS supplier_profiles ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + typ_subjektu TEXT NOT NULL DEFAULT 'osoba', + nazev_spolecnosti TEXT NOT NULL DEFAULT '', + ico TEXT NOT NULL DEFAULT '', + jmeno TEXT NOT NULL DEFAULT '', + prijmeni TEXT NOT NULL DEFAULT '', + ulice_cp TEXT NOT NULL DEFAULT '', + psc TEXT NOT NULL DEFAULT '', + mesto TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS clients ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + typ_subjektu TEXT NOT NULL DEFAULT 'osoba', + nazev_spolecnosti TEXT NOT NULL DEFAULT '', + ico TEXT NOT NULL DEFAULT '', + jmeno TEXT NOT NULL DEFAULT '', + prijmeni TEXT NOT NULL DEFAULT '', + ulice_cp TEXT NOT NULL DEFAULT '', + psc TEXT NOT NULL DEFAULT '', + mesto TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS invoices ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invoice_number TEXT NOT NULL, + invoice_data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, invoice_number) + ); + + CREATE TABLE IF NOT EXISTS invoice_counters ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invoice_year INTEGER NOT NULL, + last_number INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, invoice_year) + ); + + CREATE TABLE IF NOT EXISTS payment_presets ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + account_number TEXT NOT NULL DEFAULT '', + iban TEXT NOT NULL DEFAULT '', + swift TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, name) + ); + + ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT ''; + ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba'; + ALTER TABLE clients ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT ''; + ALTER TABLE clients ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba'; + `).catch((error) => { + schemaPromise = undefined; + throw error; + }); + } + + return schemaPromise; +}; + +module.exports = { pool, initializeDatabase }; diff --git a/app/src/invoice-data.js b/app/src/invoice-data.js new file mode 100644 index 0000000..4d0fb76 --- /dev/null +++ b/app/src/invoice-data.js @@ -0,0 +1,28 @@ +const normalizeInvoiceData = (data) => { + if (!data || !Array.isArray(data.items)) { + return null; + } + + const items = data.items.map((item) => ({ + popis: typeof item.popis === 'string' ? item.popis.trim() : '', + mnozstvi: Number(item.mnozstvi), + mernaJednotka: typeof item.mernaJednotka === 'string' ? item.mernaJednotka.trim() : '', + cenaZaMj: Number(item.cenaZaMj) + })); + if (items.some((item) => !Number.isFinite(item.mnozstvi) + || !Number.isFinite(item.cenaZaMj) + || item.mnozstvi < 0 + || item.cenaZaMj < 0)) { + return null; + } + + return { + supplier: data.supplier || {}, + customer: data.customer || {}, + payment: data.payment || {}, + items, + total: items.reduce((sum, item) => sum + item.mnozstvi * item.cenaZaMj, 0) + }; +}; + +module.exports = { normalizeInvoiceData }; diff --git a/app/src/server.js b/app/src/server.js index d8f1e1f..90190aa 100644 --- a/app/src/server.js +++ b/app/src/server.js @@ -1,7 +1,13 @@ const app = require('./app'); +const { initializeDatabase } = require('./db'); const port = 3001; -app.listen(port, () => { - console.log(`App listening on port ${port}`); -}); \ No newline at end of file +initializeDatabase() + .then(() => app.listen(port, () => { + console.log(`App listening on port ${port}`); + })) + .catch((error) => { + console.error('Unable to initialize database', error); + process.exit(1); + }); \ No newline at end of file diff --git a/app/src/static/index.html b/app/src/static/index.html index 029fb44..c561517 100644 --- a/app/src/static/index.html +++ b/app/src/static/index.html @@ -7,10 +7,106 @@ Fakturovač -
-
+
+
+

FAKTUROVAČ

+

Vaše faktury, bezpečně po ruce

+

Přihlaste se a mějte dodavatele i odběratele uložené ve svém účtu.

+
+
+ + +
+
+ + +
+ + + + +
+
+
+ +
+ + + + +
+
+
+

ARCHIV

+

Vaše faktury

+
+
+
+
+ +