This commit is contained in:
seb
2026-07-31 05:54:13 +02:00
parent 6db7d4d75c
commit db8b192094
32 changed files with 100 additions and 2048 deletions

View File

@@ -8,10 +8,7 @@ CORS_ORIGINS=http://localhost:5173
GOOGLE_CLIENT_ID=
JWT_SECRET=fallback_secret_please_change_in_production
# Database — SQLite (auth allowlist)
SQLITE_PATH=data/dashback.db
# Database — MSSQL (JTL-Wawi article data)
# Database — MSSQL (auth allowlist and JTL-Wawi article data)
MSSQL_SERVER=
MSSQL_DATABASE=
MSSQL_USER=

View File

@@ -1,6 +1,6 @@
# dashback
Backend API for Dash — Google SSO auth, SQLite allowlist, and session cookies.
Backend API for Dash — Google SSO auth, MSSQL allowlist, and session cookies.
## Setup

359
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"better-sqlite3": "^12.11.1",
"chalk": "^5.6.2",
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
@@ -20,11 +19,24 @@
"ink": "^7.1.0",
"ink-text-input": "^6.0.0",
"jsonwebtoken": "^9.0.3",
"libdashstore": "file:../dashdbschema",
"mssql": "^12.6.0",
"react": "^19.2.7",
"sharp": "^0.35.2"
}
},
"../dashdbschema": {
"name": "libdashstore",
"version": "0.1.0",
"license": "ISC",
"dependencies": {
"@clack/prompts": "^1.7.0",
"@hpcc-js/wasm-graphviz": "^1.28.0",
"chalk": "^6.0.0",
"dotenv": "^17.4.2",
"mssql": "^12.7.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz",
@@ -993,20 +1005,6 @@
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@@ -1016,26 +1014,6 @@
"node": "*"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1073,30 +1051,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"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.1.13"
}
},
"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",
@@ -1168,12 +1122,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/cli-boxes": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz",
@@ -1361,30 +1309,6 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/default-browser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
@@ -1493,15 +1417,6 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
@@ -1596,15 +1511,6 @@
"node": ">=0.8.x"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -1686,12 +1592,6 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -1743,12 +1643,6 @@
"node": ">= 0.8"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1835,12 +1729,6 @@
"node": ">= 0.4"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/google-auth-library": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz",
@@ -2003,12 +1891,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ink": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/ink/-/ink-7.1.0.tgz",
@@ -2244,6 +2126,10 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/libdashstore": {
"resolved": "../dashdbschema",
"link": true
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -2350,33 +2236,6 @@
"node": ">=6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2402,12 +2261,6 @@
"node": ">=18.19.0"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/native-duplexpair": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
@@ -2423,18 +2276,6 @@
"node": ">= 0.6"
}
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -2576,33 +2417,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -2625,16 +2439,6 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -2674,21 +2478,6 @@
"node": ">= 0.10"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -2713,20 +2502,6 @@
"react": "^19.2.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/restore-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
@@ -3009,51 +2784,6 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"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/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"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": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/slice-ansi": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz",
@@ -3137,15 +2867,6 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
@@ -3158,34 +2879,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tarn": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
@@ -3295,18 +2988,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-fest": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
@@ -3368,12 +3049,6 @@
"node": ">= 0.8"
}
},
"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/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -10,13 +10,9 @@
"start": "node src/index.js",
"dev": "node --watch-path=./src --watch-path=./.env src/index.js",
"manage-users": "node scripts/manageUsers.js",
"search-articles": "node scripts/searchArticles.js",
"get-steuer": "node scripts/getSteuer.js",
"get-categories": "node scripts/getCategories.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
"chalk": "^5.6.2",
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
@@ -27,6 +23,7 @@
"ink": "^7.1.0",
"ink-text-input": "^6.0.0",
"jsonwebtoken": "^9.0.3",
"libdashstore": "file:../dashdbschema",
"mssql": "^12.6.0",
"react": "^19.2.7",
"sharp": "^0.35.2"

View File

@@ -1,90 +0,0 @@
import React, { useState } from 'react';
import { render, Box, Text, useApp, useInput } from 'ink';
import chalk from 'chalk';
import { getCategories } from '../src/services/misc/getCategories.js';
import { closePool } from '../src/db/mssqlClient.js';
import '../src/config/env.js';
const e = React.createElement;
const VISIBLE_ROWS = 20;
function flattenTree(nodes, depth = 0, out = []) {
for (const node of nodes) {
out.push({ ...node, depth });
flattenTree(node.children, depth + 1, out);
}
return out;
}
function formatRow(row) {
const indent = ' '.repeat(row.depth);
const count = chalk.gray(` (${row.count})`);
return `${indent}${row.cName ?? '—'}${count} ${chalk.dim(String(row.kKategorie))}`;
}
const CategoryBrowser = ({ rows }) => {
const { exit } = useApp();
const [selected, setSelected] = useState(0);
const windowStart = Math.max(0, Math.min(selected - Math.floor(VISIBLE_ROWS / 2), rows.length - VISIBLE_ROWS));
const visible = rows.slice(windowStart, windowStart + VISIBLE_ROWS);
const current = rows[selected];
const quit = () => {
closePool()
.catch(() => {})
.finally(() => {
exit();
process.exit(0);
});
};
useInput((input, key) => {
if (input === 'q' || (key.ctrl && input === 'c')) {
quit();
return;
}
if (key.upArrow) {
setSelected((s) => Math.max(0, s - 1));
}
if (key.downArrow) {
setSelected((s) => Math.min(rows.length - 1, s + 1));
}
});
return e(Box, { flexDirection: 'column' },
e(Text, null, chalk.cyan.bold(`=== Categories (${rows.length}) ===`)),
e(Box, { marginTop: 1, flexDirection: 'column' },
visible.map((row, index) => {
const absoluteIndex = windowStart + index;
const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' ';
const label = absoluteIndex === selected
? chalk.cyan.bold(formatRow(row))
: formatRow(row);
return e(Text, { key: row.kKategorie }, prefix, label);
})
),
rows.length > VISIBLE_ROWS
? e(Text, { dimColor: true }, `Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`)
: null,
current
? e(Box, { marginTop: 1, flexDirection: 'column' },
e(Text, { dimColor: true },
`kKategorie: ${current.kKategorie} parent: ${current.kOberKategorie} sort: ${current.nSort ?? '—'} articles: ${current.count}`
)
)
: null,
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('↑↓ navigate, q exit')))
);
};
try {
const { tree } = await getCategories();
const rows = flattenTree(tree);
render(e(CategoryBrowser, { rows }));
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
await closePool().catch(() => {});
process.exit(1);
}

View File

@@ -1,52 +0,0 @@
import React from 'react';
import { render, Box, Text, useApp, useInput } from 'ink';
import chalk from 'chalk';
import { getSteuer } from '../src/services/misc/getSteuer.js';
import { closePool } from '../src/db/mssqlClient.js';
import '../src/config/env.js';
const e = React.createElement;
function SteuerTable({ steuerklassen }) {
return e(Box, { flexDirection: 'column' },
e(Text, null, chalk.cyan.bold('=== Steuerklassen ===')),
e(Box, { marginTop: 1, flexDirection: 'column' },
e(Text, { dimColor: true }, ' ID Rate Name'),
...steuerklassen.map((row) =>
e(Text, { key: row.kSteuerklasse },
' ',
chalk.white(String(row.kSteuerklasse).padEnd(4)),
chalk.yellow(`${String(row.fSteuersatz).padStart(3)}%`.padEnd(8)),
row.cName ?? '—',
row.nStandard === 1 ? chalk.gray(' (standard)') : ''
)
)
),
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Press any key to exit')))
);
}
const App = ({ steuerklassen }) => {
const { exit } = useApp();
useInput(() => {
closePool()
.catch(() => {})
.finally(() => {
exit();
process.exit(0);
});
});
return e(SteuerTable, { steuerklassen });
};
try {
const { steuerklassen } = await getSteuer();
render(e(App, { steuerklassen }));
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
await closePool().catch(() => {});
process.exit(1);
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { render, Box, Text, useApp, useInput } from 'ink';
import TextInput from 'ink-text-input';
import chalk from 'chalk';
@@ -48,6 +48,17 @@ const App = () => {
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [emails, setEmails] = useState(null);
useEffect(() => {
if (mode !== MODES.LIST) {
return;
}
setEmails(null);
googleAuth.listAllowedEmails()
.then(setEmails)
.catch((err) => finish(false, err.message));
}, [mode]);
const finish = (success, text) => {
if (success) {
@@ -72,28 +83,28 @@ const App = () => {
setMode(action);
};
const handleAdd = () => {
const handleAdd = async () => {
try {
const trimmed = email.trim();
if (!trimmed || !trimmed.includes('@')) {
finish(false, 'Invalid email address');
return;
}
googleAuth.addAllowedEmail(trimmed);
await googleAuth.addAllowedEmail(trimmed);
finish(true, `Added ${trimmed.toLowerCase()} to google_auth`);
} catch (err) {
finish(false, err.message);
}
};
const handleRemove = () => {
const handleRemove = async () => {
try {
const trimmed = email.trim();
if (!trimmed) {
finish(false, 'Email is required');
return;
}
const removed = googleAuth.removeAllowedEmail(trimmed);
const removed = await googleAuth.removeAllowedEmail(trimmed);
finish(true, removed ? `Removed ${trimmed.toLowerCase()}` : `Email not found: ${trimmed}`);
} catch (err) {
finish(false, err.message);
@@ -107,7 +118,9 @@ const App = () => {
return e(Text, null, chalk.red(`Error: ${error}`));
}
if (mode === MODES.LIST) {
const emails = googleAuth.listAllowedEmails();
if (!emails) {
return e(Text, null, chalk.gray('Loading...'));
}
if (emails.length === 0) {
finish(true, 'No allowed emails in google_auth');
return e(Text, null, chalk.yellow('No allowed emails...'));

View File

@@ -1,401 +0,0 @@
import React, { useState } from 'react';
import { render, Box, Text, useApp, useInput } from 'ink';
import TextInput from 'ink-text-input';
import chalk from 'chalk';
import { getArticle } from '../src/services/article/getArticle.js';
import { searchArticles } from '../src/services/article/searchArticles.js';
import { closePool } from '../src/db/mssqlClient.js';
import { bruttoFromNetto, loadSteuerklassen } from '../src/services/misc/getSteuer.js';
import '../src/config/env.js';
const e = React.createElement;
const MODES = {
SEARCH: 'search',
LOADING: 'loading',
LIST: 'list',
RESULT: 'result',
ERROR: 'error',
};
const VISIBLE_ROWS = 15;
const eur = (value) => {
if (value == null || Number.isNaN(Number(value))) {
return '—';
}
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
};
function formatPrice(row, steuerklassen) {
if (row.fVKNEtto == null) {
return '—';
}
const brutto = bruttoFromNetto(row.fVKNEtto, row.kSteuerklasse, steuerklassen);
return brutto == null ? '—' : eur(brutto);
}
function hasValue(value) {
if (value == null || value === '' || value === 0) {
return false;
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}
function DetailRow({ label, value, alwaysShow = false }) {
if (!alwaysShow && !hasValue(value) && value !== 0 && value !== '0') {
return null;
}
const text = value == null || value === '' ? '—' : String(value);
return e(Box, null,
e(Text, { dimColor: true }, `${label.padEnd(14)} `),
e(Text, null, text)
);
}
function Section({ title, children }) {
const items = React.Children.toArray(children).filter(Boolean);
if (!items.length) {
return null;
}
return e(Box, { flexDirection: 'column', marginTop: 1 },
e(Text, null, chalk.cyan.bold(title)),
...items
);
}
function ArticleDetail({ article, steuerklassen }) {
const brutto = bruttoFromNetto(article.fVKNEtto, article.kSteuerklasse, steuerklassen);
const aktiv = article.cAktiv === 'Y'
? chalk.green('active')
: article.cAktiv === 'N'
? chalk.red('inactive')
: article.cAktiv;
return e(Box, { flexDirection: 'column' },
e(Text, null, chalk.white.bold(article.cName ?? '—')),
e(Text, { dimColor: true }, article.cArtNr ?? '—'),
e(Section, { title: 'Overview' },
e(DetailRow, { label: 'Artikel-ID', value: article.kArtikel }),
e(DetailRow, { label: 'Status', value: aktiv }),
e(DetailRow, { label: 'Hersteller', value: article.cHersteller }),
e(DetailRow, { label: 'Barcode', value: article.cBarcode }),
article.EANs?.length
? e(DetailRow, { label: 'EANs', value: article.EANs.map((row) => row.cEAN).join(', ') })
: null
),
e(Section, { title: 'Pricing' },
e(DetailRow, { label: 'VK brutto', value: brutto != null ? eur(brutto) : null }),
e(DetailRow, { label: 'VK netto', value: article.fVKNEtto != null ? eur(article.fVKNEtto) : null }),
e(DetailRow, { label: 'Steuerklasse', value: article.kSteuerklasse }),
hasValue(article.fUVP) ? e(DetailRow, { label: 'UVP', value: eur(article.fUVP) }) : null,
article.staffel?.length
? e(Box, { flexDirection: 'column', marginTop: 0 },
e(Text, { dimColor: true }, 'Staffel'),
...article.staffel.map((row, index) =>
e(Text, { key: index, dimColor: true }, ` ab ${row.menge} ${row.fVKBrutto} €`)
)
)
: null
),
e(Section, { title: 'Stock' },
e(DetailRow, { label: 'Lager 1', value: article.fBestand, alwaysShow: true }),
hasValue(article.garage) ? e(DetailRow, { label: 'Garage', value: article.garage }) : null,
hasValue(article.keller) ? e(DetailRow, { label: 'Keller', value: article.keller }) : null,
article.fAndereLager?.length
? e(DetailRow, {
label: 'Andere Lager',
value: article.fAndereLager.map((row) => row.fSumme).join(', '),
})
: null,
article.incoming?.length
? e(Text, { dimColor: true }, ` ${article.incoming.length} open supplier order(s)`)
: null
),
e(Section, { title: 'Product' },
e(DetailRow, { label: 'Versandklasse', value: article.tVersandklassecName ?? article.kVersandklasse }),
hasValue(article.fGewicht) ? e(DetailRow, { label: 'Gewicht', value: `${article.fGewicht} kg` }) : null,
hasValue(article.shops) ? e(DetailRow, { label: 'Shops', value: article.shops }) : null,
article.KATs?.length
? e(DetailRow, { label: 'Kategorien', value: article.KATs.map((row) => row.kKategorie).join(', ') })
: null,
article.merkmale?.length
? e(Box, { flexDirection: 'column' },
e(Text, { dimColor: true }, 'Merkmale'),
...article.merkmale.map((row, index) =>
e(Text, { key: index, dimColor: true }, ` ${row.cName}: ${row.cWert}`)
)
)
: null
),
article.lief?.length
? e(Section, { title: `Suppliers (${article.lief.length})` },
...article.lief.slice(0, 8).map((row, index) =>
e(Text, { key: index, dimColor: true },
` ${row.nStandard ? chalk.yellow('* ') : ' '}${row.cFirma ?? '—'} ${row.cLiefArtNr ?? '—'} EK ${eur(row.fEKNetto)}`
)
),
article.lief.length > 8
? e(Text, { dimColor: true }, ` … +${article.lief.length - 8} more`)
: null
)
: null,
article.children?.length
? e(Section, { title: `Variants (${article.children.length})` },
...article.children.slice(0, 6).map((row, index) =>
e(Text, { key: index, dimColor: true },
` ${row.cArtNr} ${row.cName ?? '—'}`
)
)
)
: null,
article.liste?.length
? e(Section, { title: `Stückliste (${article.liste.length})` },
...article.liste.map((row, index) =>
e(Text, { key: index, dimColor: true },
` ${row.cArtNr} × ${row.fAnzahl} ${row.cName ?? '—'}`
)
)
)
: null,
article.orders?.length
? e(Section, { title: 'Open orders' },
...article.orders.map((row, index) =>
e(Text, { key: index, dimColor: true }, ` ${row.cType} ${row.cNote ?? ''}`)
)
)
: null,
hasValue(article.cBeschreibung)
? e(Section, { title: 'Description' },
e(Text, null, truncateForDisplay(article.cBeschreibung, 240))
)
: null
);
}
function truncateForDisplay(value, max = 80) {
if (typeof value !== 'string' || value.length <= max) {
return value;
}
return `${value.slice(0, max)}… (${value.length} chars)`;
}
function formatListRow(row) {
const stock = row.stock ?? '—';
const price = row.price == null ? '—' : eur(row.price);
const name = row.name ?? '—';
return `${row.sku ?? '—'} ${stock} ${price} ${name}`;
}
const ResultList = ({ rows, onSelect, onBack }) => {
const [selected, setSelected] = useState(0);
const windowStart = Math.max(0, Math.min(selected - Math.floor(VISIBLE_ROWS / 2), rows.length - VISIBLE_ROWS));
const visible = rows.slice(windowStart, windowStart + VISIBLE_ROWS);
useInput((_input, key) => {
if (key.escape) {
onBack();
return;
}
if (key.upArrow) {
setSelected((s) => Math.max(0, s - 1));
}
if (key.downArrow) {
setSelected((s) => Math.min(rows.length - 1, s + 1));
}
if (key.return && rows[selected]) {
onSelect(rows[selected]);
}
});
return e(Box, { flexDirection: 'column' },
e(Text, null, chalk.cyan.bold(`${rows.length} result(s) — ↑↓ select, Enter open, Esc back`)),
visible.map((row, index) => {
const absoluteIndex = windowStart + index;
const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' ';
const label = absoluteIndex === selected
? chalk.cyan.bold(formatListRow(row))
: formatListRow(row);
return e(Text, { key: row.id ?? absoluteIndex }, prefix, label);
}),
rows.length > VISIBLE_ROWS
? e(Text, null, chalk.gray(`Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`))
: null
);
};
const App = ({ steuerklassen }) => {
const { exit } = useApp();
const [mode, setMode] = useState(MODES.SEARCH);
const [target, setTarget] = useState('');
const [rows, setRows] = useState([]);
const [article, setArticle] = useState(null);
const [error, setError] = useState('');
const [loadingLabel, setLoadingLabel] = useState('Searching…');
const quit = async (code = 0) => {
try {
await closePool();
} catch {
// ignore cleanup errors
}
exit();
process.exit(code);
};
const loadArticle = async (cArtNr) => {
setLoadingLabel(`Loading ${cArtNr}…`);
setMode(MODES.LOADING);
setError('');
try {
const result = await getArticle(cArtNr);
if (!result) {
setError(`Article not found: ${cArtNr}`);
setMode(MODES.ERROR);
return;
}
setArticle(result);
setMode(MODES.RESULT);
} catch (err) {
setError(err.message);
setMode(MODES.ERROR);
}
};
const handleSearch = async () => {
const trimmed = target.trim();
if (!trimmed) {
setError('Search terms are required');
setMode(MODES.ERROR);
return;
}
setLoadingLabel('Searching…');
setMode(MODES.LOADING);
setError('');
try {
const { recordset } = await searchArticles(trimmed);
if (!recordset.length) {
setError(`No articles found for: ${trimmed}`);
setMode(MODES.ERROR);
return;
}
if (recordset.length === 1) {
await loadArticle(recordset[0].sku);
return;
}
setRows(recordset);
setMode(MODES.LIST);
} catch (err) {
setError(err.message);
setMode(MODES.ERROR);
}
};
const backToSearch = () => {
setRows([]);
setArticle(null);
setError('');
setMode(MODES.SEARCH);
};
const resetSearch = () => {
setTarget('');
backToSearch();
};
if (mode === MODES.LOADING) {
return e(Text, null, chalk.yellow(loadingLabel));
}
if (mode === MODES.ERROR) {
return e(Box, { flexDirection: 'column' },
e(Text, null, chalk.red(`Error: ${error}`)),
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Press any key to try again'))),
e(ExitOnKey, { onExit: backToSearch })
);
}
if (mode === MODES.LIST) {
return e(ResultList, {
rows,
onSelect: (row) => loadArticle(row.sku),
onBack: backToSearch,
});
}
const backToList = () => {
setArticle(null);
setError('');
setMode(rows.length ? MODES.LIST : MODES.SEARCH);
};
if (mode === MODES.RESULT) {
const footer = rows.length
? 'Esc = back to results, Enter = new search, q = quit'
: 'Enter = new search, q = quit';
return e(Box, { flexDirection: 'column' },
e(ArticleDetail, { article, steuerklassen }),
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray(footer))),
e(ResultInput, {
onAgain: resetSearch,
onBack: backToList,
onQuit: () => quit(0),
canGoBack: rows.length > 0,
})
);
}
return e(Box, { flexDirection: 'column' },
e(Box, { marginBottom: 1 },
e(Text, null, chalk.cyan.bold('=== Article Search ==='))
),
e(Text, null, chalk.gray('Partial match on name, number, EAN, barcode, supplier number')),
e(Box, { marginTop: 1 },
e(Text, null, 'Target: '),
e(TextInput, { value: target, onChange: setTarget, onSubmit: handleSearch })
),
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Enter to search, Ctrl+C to quit')))
);
};
const ResultInput = ({ onAgain, onBack, onQuit, canGoBack }) => {
useInput((_input, key) => {
if (key.escape && canGoBack) {
onBack();
return;
}
if (_input === 'q' || (key.ctrl && _input === 'c')) {
onQuit();
return;
}
if (key.return) {
onAgain();
}
});
return null;
};
const ExitOnKey = ({ onExit }) => {
useInput(onExit);
return null;
};
const steuerklassen = await loadSteuerklassen();
render(e(App, { steuerklassen }));

View File

@@ -7,7 +7,7 @@ import cookieParser from 'cookie-parser';
import { loggerMiddleware } from './middleware/logger.js';
import env from '../config/env.js';
import { authMiddleware } from './middleware/auth.js';
import { ensureConnection } from '../db/client.js';
import { ensureConnection } from '../db/store.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

View File

@@ -6,17 +6,17 @@ import { meJson } from '../auth/meJson.js';
const userCache = new Map();
const USER_CACHE_TTL = 10 * 60 * 1000;
export function getCachedUser(id) {
const cached = userCache.get(id);
export function getCachedUser(email) {
const cached = userCache.get(email);
if (cached && cached.expiresAt > Date.now()) {
return cached.user;
}
userCache.delete(id);
userCache.delete(email);
return null;
}
export function setCachedUser(id, user) {
userCache.set(id, { user, expiresAt: Date.now() + USER_CACHE_TTL });
export function setCachedUser(email, user) {
userCache.set(email, { user, expiresAt: Date.now() + USER_CACHE_TTL });
if (userCache.size > 1000) {
const now = Date.now();
for (const [k, v] of userCache) {
@@ -25,8 +25,8 @@ export function setCachedUser(id, user) {
}
}
export function clearCachedUser(id) {
userCache.delete(id);
export function clearCachedUser(email) {
userCache.delete(email);
}
function authMeJson(res, req, status, body) {
@@ -51,10 +51,10 @@ export const authMiddleware = async (req, res, next) => {
try {
const decoded = jwt.verify(token, env.jwtSecret);
let user = getCachedUser(decoded.id);
let user = getCachedUser(decoded.email);
if (!user) {
user = db.getAllowedEmailById(decoded.id);
if (user) setCachedUser(decoded.id, user);
user = await db.getAllowedEmail(decoded.email);
if (user) setCachedUser(decoded.email, user);
}
if (!user) {
return authMeJson(res, req, 403, { error: 'User is no longer authorized' });
@@ -63,6 +63,7 @@ export const authMiddleware = async (req, res, next) => {
req.user = {
id: user.id,
email: user.email,
selectedCompanyId: user.selectedCompanyId,
...(decoded.picture && { picture: decoded.picture }),
};

View File

@@ -1,34 +0,0 @@
import { getArticle } from '../../../services/article/getArticle.js';
/**
* Registers the full article lookup route backed by MSSQL.
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
*/
export function register(app, basePath = '') {
/**
* @route GET /article/get
* @description Look up a single article by exact article number, EAN, or barcode.
* @query {string} target - Article number, packaging EAN, or barcode
* @returns {Object} 200 - `{ article }` with enriched article data
* @returns {Object} 400 - `{ error }` when target is missing
* @returns {Object} 404 - `{ error }` when no article matches
*/
app.get(`${basePath}/article/get`, async (req, res) => {
const target = req.query.target;
if (!target || !String(target).trim()) {
return res.status(400).json({ error: 'Query parameter "target" is required' });
}
try {
const article = await getArticle(String(target));
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
return res.status(200).json({ article });
} catch (error) {
return res.status(500).json({ error: error.message });
}
});
}

View File

@@ -1,36 +0,0 @@
import { parseFilter, searchArticles } from '../../../services/article/searchArticles.js';
/**
* Registers the article search route backed by MSSQL.
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
*/
export function register(app, basePath = '') {
/**
* @route GET /article/search
* @description Search articles by partial match on name, number, EAN, barcode, or supplier number.
* @query {string} target - Space-separated search terms
* @query {string} [filter] - Optional comma-separated category filter IDs (empty = all)
* @query {number} [offset=0] - Number of results to skip
* @query {number} [maxCount=10] - Maximum number of results to return
* @returns {Object} 200 - `{ categories, length, recordset }`
* @returns {Object} 400 - `{ error }` when target is missing
*/
app.get(`${basePath}/article/search`, async (req, res) => {
const target = req.query.target;
if (!target || !String(target).trim()) {
return res.status(400).json({ error: 'Query parameter "target" is required' });
}
try {
const filter = parseFilter(req.query.filter);
const offset = req.query.offset !== undefined ? Number(req.query.offset) : 0;
const maxCount = req.query.maxCount !== undefined ? Number(req.query.maxCount) : 10;
const result = await searchArticles(String(target), filter, offset, maxCount);
return res.status(200).json(result);
} catch (error) {
return res.status(500).json({ error: error.message });
}
});
}

View File

@@ -15,7 +15,7 @@ export function register(app, basePath = '') {
* @route POST /auth/login
* @description Verifies a Google ID token, checks allowlist, and sets an HTTP-only `token` cookie.
* @body {string} idToken - Google ID token from Google Identity Services
* @returns {Object} 200 - `{ message, user }` with `{ id, email, picture? }` in `user`
* @returns {Object} 200 - `{ message, user }` with `{ id, email, selectedCompanyId, picture? }` in `user`
* @returns {Object} 400 - Missing token or Google client not configured
* @returns {Object} 401 - Invalid Google token
* @returns {Object} 403 - Email not on allowlist
@@ -43,7 +43,7 @@ export function register(app, basePath = '') {
return res.status(401).json({ error: 'Invalid Google account' });
}
const allowed = db.getAllowedEmail(email);
const allowed = await db.getAllowedEmail(email);
if (!allowed) {
console.warn(`Login rejected: email not authorized (${email})`);
return res.status(403).json({ error: 'Email is not authorized' });
@@ -52,6 +52,7 @@ export function register(app, basePath = '') {
const user = {
id: allowed.id,
email: allowed.email,
selectedCompanyId: allowed.selectedCompanyId,
...(payload.picture && { picture: payload.picture }),
};
const token = jwt.sign(user, env.jwtSecret, { expiresIn: '1d' });

View File

@@ -18,8 +18,8 @@ export function register(app, basePath = '') {
if (token) {
try {
const decoded = jwt.verify(token, env.jwtSecret, { ignoreExpiration: true });
if (decoded?.id) {
clearCachedUser(decoded.id);
if (decoded?.email) {
clearCachedUser(decoded.email);
}
} catch {
// Ignore errors during cache clearing on logout

View File

@@ -9,11 +9,11 @@ export function register(app, basePath = '') {
/**
* @route GET /auth/me
* @description Returns the authenticated user from `req.user` and the Google client ID.
* @returns {Object} 200 - `{ user: { id, email, picture? }, googleClientId }`
* @returns {Object} 200 - `{ user: { id, email, selectedCompanyId, picture? }, googleClientId }`
*/
app.get(`${basePath}/auth/me`, (req, res) => {
const { id, email, picture } = req.user;
const user = { id, email, ...(picture && { picture }) };
const { id, email, selectedCompanyId, picture } = req.user;
const user = { id, email, selectedCompanyId, ...(picture && { picture }) };
meJson(res, 200, { user });
});
}

View File

@@ -1,4 +1,4 @@
import { ensureConnection } from '../../db/client.js';
import { ensureConnection } from '../../db/store.js';
/**
* Registers a database connectivity health check route.

View File

@@ -1,20 +0,0 @@
import { getCategories } from '../../../services/misc/getCategories.js';
/**
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @route GET /misc/getCategories
* @returns {Object} 200 - `{ categories, tree }`
*/
app.get(`${basePath}/misc/getCategories`, async (_req, res) => {
try {
const result = await getCategories();
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
}

View File

@@ -1,19 +0,0 @@
import { getSteuer } from '../../../services/misc/getSteuer.js';
/**
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @route GET /misc/getSteuer
* @returns {Object} 200 - `{ steuerklassen }`
*/
app.get(`${basePath}/misc/getSteuer`, async (_req, res) => {
try {
res.status(200).json(await getSteuer());
} catch (error) {
res.status(500).json({ error: error.message });
}
});
}

View File

@@ -5,13 +5,6 @@ import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: resolve(__dirname, '../../.env'), quiet: true });
function parseIntList(value) {
return (value || '')
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
}
export default {
port: Number(process.env.PORT) || 4208,
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173')
@@ -20,7 +13,6 @@ export default {
.filter(Boolean),
googleClientId: process.env.GOOGLE_CLIENT_ID,
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',
sqlitePath: process.env.SQLITE_PATH,
mssql: {
server: process.env.MSSQL_SERVER,
database: process.env.MSSQL_DATABASE,
@@ -30,9 +22,4 @@ export default {
encrypt: process.env.MSSQL_ENCRYPT === 'true',
trustServerCertificate: process.env.MSSQL_TRUST_SERVER_CERTIFICATE === 'true',
},
article: {
katSkip: parseIntList(process.env.KAT_SKIP),
katLists: Number(process.env.KAT_LISTS) || 0,
},
steuerZone: process.env.STEUER_ZONE || 'Zone-EU',
};

View File

@@ -1,46 +0,0 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import env from '../config/env.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const defaultPath = resolve(__dirname, '../../data/dashback.db');
let db;
function getDbPath() {
return env.sqlitePath || defaultPath;
}
function initSchema(database) {
database.exec(`
CREATE TABLE IF NOT EXISTS google_auth (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
}
export function getDb() {
if (!db) {
const dbPath = getDbPath();
mkdirSync(dirname(dbPath), { recursive: true });
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
initSchema(db);
}
return db;
}
export function ensureConnection() {
getDb().prepare('SELECT 1').get();
}
export function closeDb() {
if (db) {
db.close();
db = undefined;
}
}

View File

@@ -1,34 +1,27 @@
import { getDb } from './client.js';
import { getStore } from './store.js';
export function listAllowedEmails() {
return getDb()
.prepare('SELECT id, email, created_at FROM google_auth ORDER BY email COLLATE NOCASE')
.all();
export async function listAllowedEmails() {
const store = await getStore();
return store.google_auth.list();
}
export function getAllowedEmail(email) {
return getDb()
.prepare('SELECT id, email, created_at FROM google_auth WHERE email = ? COLLATE NOCASE')
.get(email);
}
export function getAllowedEmailById(id) {
return getDb()
.prepare('SELECT id, email, created_at FROM google_auth WHERE id = ?')
.get(id);
}
export function addAllowedEmail(email) {
export async function getAllowedEmail(email) {
const normalized = email.trim().toLowerCase();
const result = getDb()
.prepare('INSERT INTO google_auth (email) VALUES (?)')
.run(normalized);
return getAllowedEmailById(result.lastInsertRowid);
const store = await getStore();
return (await store.google_auth.getByEmail(normalized)) ?? null;
}
export function removeAllowedEmail(email) {
export async function addAllowedEmail(email) {
const normalized = email.trim().toLowerCase();
return getDb()
.prepare('DELETE FROM google_auth WHERE email = ? COLLATE NOCASE')
.run(normalized).changes > 0;
const store = await getStore();
const id = await store.google_auth.add(normalized);
return { id, email: normalized };
}
export async function removeAllowedEmail(email) {
const row = await getAllowedEmail(email);
if (!row) return false;
const store = await getStore();
await store.google_auth.del(row.id);
return true;
}

View File

@@ -1,28 +0,0 @@
export const LOAD_KAT_MAP = `
SELECT kKategorie, kOberKategorie
FROM dbo.tkategorie
`;
export const LOAD_CATEGORIES = `
SELECT
kKategorie,
kOberKategorie,
nSort,
(
SELECT cName
FROM dbo.tKategorieSprache
WHERE kSprache = 1
AND kPlattform = 1
AND kShop = 0
AND dbo.tkategorie.kKategorie = dbo.tKategorieSprache.kKategorie
) AS cName
FROM dbo.tkategorie
ORDER BY nSort, cName
`;
export const CATEGORY_ARTICLE_COUNTS = `
SELECT ka.kKategorie, COUNT(*) AS count
FROM tkategorieartikel ka
INNER JOIN tArtikel a ON a.kArtikel = ka.kArtikel AND a.cAktiv = 'Y'
GROUP BY ka.kKategorie
`;

View File

@@ -1,248 +0,0 @@
export const FIND_ARTICLE = `
SELECT TOP 1
tArtikelBeschreibung.cBeschreibung,
tArtikelBeschreibung.cKurzBeschreibung,
b.kVersandklasse,
(SELECT cName FROM dbo.tVersandklasse WHERE tVersandklasse.kVersandklasse = b.kVersandklasse) AS tVersandklassecName,
nIstVater,
kStueckliste,
kVaterArtikel,
b.kEigenschaftKombi,
b.kArtikel,
fUVP,
fGewicht,
tHersteller.cName AS cHersteller,
cBarcode,
fBestand,
b.cArtNr,
b.cAktiv,
tArtikelBeschreibung.cName,
b.fVKNEtto,
b.kSteuerklasse,
nVPE,
kMassEinheit,
fMassMenge,
kGrundPreisEinheit,
cAnmerkung,
tEigenschaftSprache.kEigenschaft,
tEigenschaftSprache.cName AS cEigenschaftName,
tEigenschaftWertSprache.kEigenschaftWert,
tEigenschaftWertSprache.cName AS cEigenschaftWertName,
(
SELECT c.cArtNr
FROM tArtikel c
WHERE c.kArtikel = (
SELECT tArtikel_kArtikel
FROM tliefartikel
WHERE cLiefArtNr = b.cArtNr AND tlieferant_klieferant = 96
)
) AS cLiefArtNr96
FROM tArtikelBeschreibung
INNER JOIN tArtikel b ON b.kArtikel = tArtikelBeschreibung.kArtikel
LEFT JOIN dbo.vLagerbestandProLager ON (
dbo.vLagerbestandProLager.kArtikel = b.kArtikel
AND vLagerbestandProLager.kWarenlager = 1
)
LEFT JOIN tGebinde ON b.kArtikel = tGebinde.kArtikel
LEFT JOIN tHersteller ON b.kHersteller = tHersteller.kHersteller
LEFT JOIN tEigenschaftKombiWert ON b.kEigenschaftKombi = tEigenschaftKombiWert.kEigenschaftKombi
LEFT JOIN tEigenschaftSprache ON (
tEigenschaftKombiWert.kEigenschaft = tEigenschaftSprache.kEigenschaft
AND tEigenschaftSprache.kSprache = 1
)
LEFT JOIN tEigenschaftWertSprache ON (
tEigenschaftWertSprache.kEigenschaftWert = tEigenschaftKombiWert.kEigenschaftWert
AND tEigenschaftWertSprache.kSprache = 1
)
WHERE b.cArtNr = @target OR tGebinde.cEAN = @target OR b.cBarcode = @target
`;
export const CHILDREN_BY_VATER = `
SELECT
tArtikel.cArtNr,
tArtikelBeschreibung.cName,
tArtikel.kEigenschaftKombi,
tEigenschaftSprache.kEigenschaft,
tEigenschaftSprache.cName AS cEigenschaftName,
tEigenschaftWertSprache.kEigenschaftWert,
tEigenschaftWertSprache.cName AS cEigenschaftWertName
FROM tArtikelBeschreibung
INNER JOIN tArtikel ON tArtikel.kArtikel = tArtikelBeschreibung.kArtikel
LEFT JOIN tEigenschaftKombiWert ON tArtikel.kEigenschaftKombi = tEigenschaftKombiWert.kEigenschaftKombi
LEFT JOIN tEigenschaftSprache ON (
tEigenschaftKombiWert.kEigenschaft = tEigenschaftSprache.kEigenschaft
AND tEigenschaftSprache.kSprache = 1
)
LEFT JOIN tEigenschaftWertSprache ON (
tEigenschaftWertSprache.kEigenschaftWert = tEigenschaftKombiWert.kEigenschaftWert
AND tEigenschaftWertSprache.kSprache = 1
)
WHERE tArtikel.kVaterArtikel = @kArtikel
`;
export const VATER_BY_ID = `
SELECT tArtikel.cArtNr
FROM tArtikelBeschreibung
INNER JOIN tArtikel ON tArtikel.kArtikel = tArtikelBeschreibung.kArtikel
WHERE tArtikel.kArtikel = @kArtikel
`;
export const STUECKLISTE = `
SELECT
tArtikelBeschreibung.cName,
tArtikel.cArtNr,
tStueckliste.fAnzahl,
tArtikel.fVKNEtto,
tArtikel.kSteuerklasse,
vLagerbestandProLager.fBestand
FROM dbo.tStueckliste
INNER JOIN dbo.tArtikelBeschreibung ON tArtikelBeschreibung.kArtikel = tStueckliste.kArtikel
LEFT JOIN dbo.vLagerbestandProLager ON (
vLagerbestandProLager.kArtikel = tStueckliste.kArtikel
AND vLagerbestandProLager.kWarenlager = 1
)
INNER JOIN dbo.tArtikel ON tArtikel.kArtikel = tStueckliste.kArtikel
WHERE tStueckliste.kStueckliste = @kStueckliste
ORDER BY tStueckliste.nSort
`;
export const ANDERE_LAGER = `
SELECT SUM(fBestand) AS fSumme
FROM dbo.vLagerbestandProLager
WHERE dbo.vLagerbestandProLager.kArtikel = @kArtikel
AND vLagerbestandProLager.kWarenlager != 1
GROUP BY dbo.vLagerbestandProLager.kArtikel
`;
export const EANS = `
SELECT cEAN
FROM tGebinde
WHERE kArtikel = @kArtikel
`;
export function categoriesQuery(katSkipClause, katLists) {
return `
SELECT kKategorie
FROM dbo.tkategorieartikel
WHERE kArtikel = @kArtikel
AND ${katSkipClause}kKategorie NOT IN (
SELECT kKategorie
FROM tKategorie
WHERE kOberKategorie = ${katLists}
)
`;
}
export const SUPPLIERS = `
SELECT
tlieferant.kLieferant,
cFirma,
tliefartikel.cLiefArtNr,
nStandard,
fLagerbestand,
cVPEEinheit,
nVPEMenge,
fMwSt,
fEKNetto,
tShopKorb.nMenge,
tlieferant.cEmail,
tlieferant.cAnmerkung
FROM tliefartikel
INNER JOIN tlieferant ON tlieferant.kLieferant = tliefartikel.tLieferant_kLieferant
LEFT JOIN tShopKorb ON (
tShopKorb.kLieferant = tlieferant.kLieferant
AND tliefartikel.cLiefArtNr = tShopKorb.cLiefArtNr
)
WHERE tArtikel_kArtikel = @kArtikel
ORDER BY nStandard DESC, tlieferant.kLieferant
`;
export const INCOMING_ORDERS = `
SELECT
tLieferantenBestellung.kLieferant,
tLieferantenBestellungPos.kLieferantenBestellungPos,
tLieferantenBestellungPos.kLieferantenBestellung,
cEigeneBestellnummer,
tLieferantenBestellungPos.kLieferantenBestellung + 1 AS kLieferantenBestellung2,
cFirma,
tLieferantenBestellungPos.dLieferdatum,
cFremdbelegnummer,
cInternerKommentar,
fAnzahlOffen
FROM tLieferantenBestellung
INNER JOIN tlieferant ON tlieferant.kLieferant = tLieferantenBestellung.kLieferant
INNER JOIN tLieferantenBestellungPos ON (
tLieferantenBestellungPos.kLieferantenBestellung = tLieferantenBestellung.kLieferantenBestellung
)
WHERE fAnzahlOffen > 0 AND kArtikel = @kArtikel
ORDER BY tLieferantenBestellungPos.kLieferantenBestellung
`;
export const OPEN_INCIDENTS = `
SELECT cType, cNote, kIncident, cEmployeeIssuer, cEmployeeMap
FROM dbo.tIncident
WHERE kRef1 = @kArtikel
AND cState = 'new'
AND cType IN ('order_count', 'order_label', 'order_free')
`;
export const MERKMALE = `
SELECT
tArtikelMerkmal.kMerkmal,
tArtikelMerkmal.kMerkmalWert,
tMerkmalSprache.cName,
tMerkmalWertSprache.cWert
FROM dbo.tArtikelMerkmal
INNER JOIN dbo.tMerkmalSprache ON tMerkmalSprache.kMerkmal = tArtikelMerkmal.kMerkmal
INNER JOIN dbo.tMerkmalWertSprache ON tMerkmalWertSprache.kMerkmalWert = tArtikelMerkmal.kMerkmalWert
WHERE tArtikelMerkmal.kArtikel = @kArtikel
ORDER BY tArtikelMerkmal.kMerkmal
`;
export const ATTRIBUTES = `
SELECT
tArtikelAttribut.kAttribut,
tArtikelAttributSprache.cWertVarchar,
tArtikelAttributSprache.nWertInt,
tArtikelAttributSprache.fWertDecimal
FROM dbo.tArtikelAttribut
INNER JOIN dbo.tArtikelAttributSprache ON (
tArtikelAttributSprache.kArtikelAttribut = tArtikelAttribut.kArtikelAttribut
AND kSprache = 0
)
WHERE kShop = 0 AND tArtikelAttribut.kArtikel = @kArtikel
`;
export const STAFFEL = `
SELECT nAnzahlAb, fNEttoPreis
FROM tPreis
INNER JOIN tPreisDetail ON tPreisDetail.kPreis = tPreis.kPreis
WHERE kArtikel = @kArtikel AND kKundenGruppe = 2 AND kShop = 0
`;
export const EXTRA_LAGER = `
SELECT nMenge
FROM tExtraLager
WHERE kArtikel = @kArtikel AND nLager = @nLager
`;
export const SHOP_SUM = `
SELECT SUM(kShop) AS kShop
FROM tArtikelShop
WHERE kArtikel = @kArtikel
`;
export const HERSTELLER_GPSR = `
SELECT
tHersteller.cBeschreibung AS cHerstellerGPSR,
b.bVorschauBild AS herstellerBild
FROM tArtikel
INNER JOIN tHersteller ON tArtikel.kHersteller = tHersteller.kHersteller
LEFT JOIN tHerstellerBildPlattform hbp ON (
hbp.kHersteller = tHersteller.kHersteller
AND hbp.kPlattform = 1
AND hbp.kShop = 0
)
LEFT JOIN tBild b ON b.kBild = hbp.kBild
WHERE tArtikel.kArtikel = @kArtikel
`;

View File

@@ -1,37 +0,0 @@
export const SELECT_COLUMNS = `
(SELECT STRING_AGG(tk1.kKategorie, ',')
FROM dbo.tkategorieartikel AS tk1
WHERE tk1.kArtikel = tArtikel.kArtikel
GROUP BY tk1.kArtikel) AS katList,
MIN(fVerfuegbar) AS fVerfuegbar,
tArtikel.kArtikel,
MIN(fBestand) AS fBestand,
MIN(tArtikel.cArtNr) AS cArtNr,
MIN(tArtikelBeschreibung.cName) AS cName,
MIN(tArtikel.fVKNEtto) AS fVKNEtto,
MIN(tArtikel.kSteuerklasse) AS kSteuerklasse
`;
export const FROM_JOINS = `
FROM tArtikelBeschreibung
INNER JOIN tArtikel ON tArtikel.kArtikel = tArtikelBeschreibung.kArtikel
LEFT JOIN tLiefArtikel ON tArtikel.kArtikel = tLiefArtikel.tArtikel_kArtikel
LEFT JOIN tGebinde ON tArtikel.kArtikel = tGebinde.kArtikel
LEFT JOIN dbo.tlagerbestand ON dbo.tlagerbestand.kArtikel = tArtikel.kArtikel
LEFT JOIN dbo.vLagerbestandProLager ON (
dbo.vLagerbestandProLager.kArtikel = tArtikel.kArtikel
AND vLagerbestandProLager.kWarenlager = 1
)
`;
export const GROUP_ORDER = `
GROUP BY tArtikel.kArtikel
ORDER BY MIN(tArtikelBeschreibung.cName)
`;
export const CATEGORY_JOIN = `
INNER JOIN dbo.tkategorieartikel ON (
tkategorieartikel.kArtikel = tArtikel.kArtikel
AND ({categoryFilter})
)
`;

View File

@@ -1,24 +0,0 @@
export const LOAD_STEUERSATZ = `
SELECT
kSteuerklasse,
fSteuersatz
FROM dbo.tSteuersatz
WHERE kSteuerzone IN (
SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @steuerZone
)
ORDER BY kSteuerklasse
`;
export const LOAD_STEUERKLASSEN = `
SELECT
sk.kSteuerklasse,
sk.cName,
sk.nStandard,
sz.fSteuersatz
FROM dbo.tSteuerklasse sk
INNER JOIN dbo.tSteuersatz sz ON sz.kSteuerklasse = sk.kSteuerklasse
WHERE sz.kSteuerzone IN (
SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @steuerZone
)
ORDER BY sk.kSteuerklasse
`;

View File

@@ -1,7 +1,9 @@
import sql from 'mssql';
import LibDashStore from 'libdashstore';
import env from '../config/env.js';
let pool;
let store;
let connecting;
function getMssqlConfig() {
@@ -29,36 +31,36 @@ function getMssqlConfig() {
};
}
export async function getPool() {
if (pool) {
return pool;
/**
* Returns the initialized LibDashStore singleton.
* @returns {Promise<InstanceType<typeof LibDashStore>>}
*/
export async function getStore() {
if (store) {
return store;
}
if (!connecting) {
connecting = new sql.ConnectionPool(getMssqlConfig())
.connect()
.then((connectedPool) => {
pool = connectedPool;
return pool;
})
.finally(() => {
connecting = undefined;
});
connecting = (async () => {
pool = await new sql.ConnectionPool(getMssqlConfig()).connect();
store = await new LibDashStore(pool);
return store;
})().finally(() => {
connecting = undefined;
});
}
return connecting;
}
export async function ensureConnection() {
const activePool = await getPool();
await activePool.request().query('SELECT 1 AS ok');
await getStore();
}
export async function closePool() {
export async function closeStore() {
if (pool) {
await pool.close();
pool = undefined;
store = undefined;
}
}
export { sql };

View File

@@ -1,13 +1,11 @@
import './config/env.js';
import { startApi, stopApi } from './api/index.js';
import { closeDb } from './db/client.js';
import { closePool } from './db/mssqlClient.js';
import { closeStore } from './db/store.js';
async function shutdown() {
await stopApi();
try {
closeDb();
await closePool();
await closeStore();
} catch (error) {
console.error('Failed to close database:', error);
process.exitCode = 1;

View File

@@ -1,15 +0,0 @@
export function enrichSupplierAnmerkungRow(row) {
const enriched = { ...row };
if (typeof row.cAnmerkung === 'string' && row.cAnmerkung.trim().startsWith('{')) {
try {
enriched.anmerkung = JSON.parse(row.cAnmerkung);
} catch {
enriched.anmerkung = row.cAnmerkung;
}
} else if (row.cAnmerkung != null) {
enriched.anmerkung = row.cAnmerkung;
}
return enriched;
}

View File

@@ -1,193 +0,0 @@
import env from '../../config/env.js';
import { getPool } from '../../db/mssqlClient.js';
import { enrichSupplierAnmerkungRow } from './enrichSupplier.js';
import { bruttoFromNetto, loadSteuerklassen } from '../misc/getSteuer.js';
import * as Q from '../../db/mssql/getArticle.js';
async function query(pool, sql, inputs = {}) {
const request = pool.request();
for (const [name, value] of Object.entries(inputs)) {
request.input(name, value);
}
const result = await request.query(sql);
return result.recordset;
}
function buildKatSkipClause() {
if (!env.article.katSkip.length) {
return '';
}
return env.article.katSkip.map((id) => `kKategorie != ${id}`).join(' AND ') + ' AND ';
}
async function findArticle(pool, target) {
const rows = await query(pool, Q.FIND_ARTICLE, { target });
return rows[0] ?? null;
}
async function loadChildren(pool, kArtikel) {
return query(pool, Q.CHILDREN_BY_VATER, { kArtikel });
}
async function loadVater(pool, kVaterArtikel) {
const rows = await query(pool, Q.VATER_BY_ID, { kArtikel: kVaterArtikel });
return rows[0] ?? null;
}
async function loadStueckliste(pool, kStueckliste) {
return query(pool, Q.STUECKLISTE, { kStueckliste });
}
async function loadWarehouseExtras(pool, kArtikel) {
const [fAndereLager, EANs, KATs] = await Promise.all([
query(pool, Q.ANDERE_LAGER, { kArtikel }),
query(pool, Q.EANS, { kArtikel }),
query(pool, Q.categoriesQuery(buildKatSkipClause(), env.article.katLists), { kArtikel }),
]);
return { fAndereLager, EANs, KATs };
}
async function loadSuppliers(pool, kArtikel) {
const rows = await query(pool, Q.SUPPLIERS, { kArtikel });
return rows.map(enrichSupplierAnmerkungRow);
}
async function loadIncomingOrders(pool, kArtikel) {
return query(pool, Q.INCOMING_ORDERS, { kArtikel });
}
async function loadOpenIncidents(pool, kArtikel) {
return query(pool, Q.OPEN_INCIDENTS, { kArtikel });
}
async function loadMerkmale(pool, kArtikel) {
return query(pool, Q.MERKMALE, { kArtikel });
}
async function loadAttributes(pool, kArtikel) {
return query(pool, Q.ATTRIBUTES, { kArtikel });
}
async function loadStaffel(pool, kArtikel, kSteuerklasse) {
const rows = await query(pool, Q.STAFFEL, { kArtikel });
const steuerklassen = await loadSteuerklassen();
return rows.map((item) => ({
menge: item.nAnzahlAb.toString(),
fVKBrutto: bruttoFromNetto(item.fNEttoPreis, kSteuerklasse, steuerklassen)?.toFixed(2),
}));
}
async function loadExtraLager(pool, kArtikel, nLager) {
const rows = await query(pool, Q.EXTRA_LAGER, { kArtikel, nLager });
return rows[0]?.nMenge;
}
async function loadShopSum(pool, kArtikel) {
const rows = await query(pool, Q.SHOP_SUM, { kArtikel });
return rows[0]?.kShop;
}
async function loadHerstellerGpsr(pool, kArtikel) {
const rows = await query(pool, Q.HERSTELLER_GPSR, { kArtikel });
const row = rows[0];
if (!row) {
return { cHerstellerGPSR: null, herstellerBild: null };
}
const herstellerBild = row.herstellerBild
? Buffer.from(row.herstellerBild).toString('base64')
: null;
return {
cHerstellerGPSR: row.cHerstellerGPSR ?? null,
herstellerBild,
};
}
async function enrichArticle(pool, article) {
const { kArtikel, kSteuerklasse, nIstVater, kVaterArtikel, kStueckliste } = article;
const parallel = [];
if (nIstVater === 1) {
parallel.push(
loadChildren(pool, kArtikel).then((children) => {
article.children = children;
})
);
}
if (kVaterArtikel) {
parallel.push(
loadVater(pool, kVaterArtikel).then((vater) => {
article.vater = vater;
})
);
}
if (kStueckliste !== 0) {
parallel.push(
loadStueckliste(pool, kStueckliste).then((liste) => {
article.liste = liste;
})
);
}
parallel.push(
loadWarehouseExtras(pool, kArtikel).then(({ fAndereLager, EANs, KATs }) => {
article.fAndereLager = fAndereLager;
article.EANs = EANs;
article.KATs = KATs;
}),
loadSuppliers(pool, kArtikel).then((lief) => {
article.lief = lief;
}),
loadIncomingOrders(pool, kArtikel).then((incoming) => {
article.incoming = incoming;
}),
loadOpenIncidents(pool, kArtikel).then((orders) => {
article.orders = orders;
}),
loadMerkmale(pool, kArtikel).then((merkmale) => {
article.merkmale = merkmale;
}),
loadAttributes(pool, kArtikel).then((attribute) => {
article.attribute = attribute;
}),
loadStaffel(pool, kArtikel, kSteuerklasse).then((staffel) => {
article.staffel = staffel;
}),
loadExtraLager(pool, kArtikel, 0).then((garage) => {
article.garage = garage;
}),
loadExtraLager(pool, kArtikel, 1).then((keller) => {
article.keller = keller;
}),
loadShopSum(pool, kArtikel).then((shops) => {
article.shops = shops;
}),
loadHerstellerGpsr(pool, kArtikel).then(({ cHerstellerGPSR, herstellerBild }) => {
article.cHerstellerGPSR = cHerstellerGPSR;
article.herstellerBild = herstellerBild;
})
);
await Promise.all(parallel);
return article;
}
export async function getArticle(target) {
const trimmed = target?.trim();
if (!trimmed) {
return null;
}
const pool = await getPool();
const article = await findArticle(pool, trimmed);
if (!article) {
return null;
}
return enrichArticle(pool, article);
}

View File

@@ -1,229 +0,0 @@
import { getPool } from '../../db/mssqlClient.js';
import { LOAD_CATEGORIES } from '../../db/mssql/categories.js';
import * as Q from '../../db/mssql/searchArticles.js';
import { bruttoFromNetto, loadSteuersatz } from '../misc/getSteuer.js';
let categoryCache;
export function parseFilter(raw) {
if (raw === undefined || raw === null || raw === '') {
return [''];
}
const parts = String(raw).split(',').map((s) => s.trim()).filter(Boolean);
return parts.length ? parts : [''];
}
function getUpPath(kat, parentMap) {
const list = [kat];
if (parentMap[kat]) {
for (const item of getUpPath(parentMap[kat], parentMap)) {
list.push(item);
}
}
return list;
}
async function loadCategoryCache(pool) {
if (categoryCache) {
return categoryCache;
}
const result = await pool.request().query(LOAD_CATEGORIES);
const byId = {};
const parentMap = {};
for (const item of result.recordset) {
byId[item.kKategorie] = {
name: item.cName ?? null,
parent: item.kOberKategorie,
};
parentMap[item.kKategorie] = item.kOberKategorie;
}
categoryCache = { byId, parentMap };
return categoryCache;
}
function buildKatAgg(recordset, parentMap) {
const counts = {};
for (const item of recordset) {
if (!item.katList) {
continue;
}
for (const kat of item.katList.split(',')) {
for (const katpart of getUpPath(kat, parentMap)) {
if (!counts[katpart]) {
counts[katpart] = 0;
}
counts[katpart]++;
}
}
}
return counts;
}
function shapeCategories(counts, byId) {
const categories = {};
for (const [id, count] of Object.entries(counts)) {
const meta = byId[id] ?? {};
categories[id] = {
count,
name: meta.name ?? null,
parent: meta.parent ?? null,
};
}
return categories;
}
/**
* Ensure every applied filter id and its ancestors appear in categories
* (count 0 if no hits), so the frontend can render hierarchy via parent links.
*/
function ensureFilterCategories(categories, filter, byId, parentMap) {
for (const id of filter) {
if (id === '' || id === null) {
continue;
}
for (const ancestorId of getUpPath(id, parentMap)) {
const key = String(ancestorId);
if (categories[key]) {
continue;
}
const meta = byId[ancestorId] ?? byId[key] ?? {};
categories[key] = {
count: 0,
name: meta.name ?? null,
parent: meta.parent ?? null,
};
}
}
return categories;
}
function shapeArticle(row, steuersaetze) {
const taxRow = steuersaetze.find((item) => item.kSteuerklasse === row.kSteuerklasse);
const brutto = bruttoFromNetto(row.fVKNEtto, row.kSteuerklasse, steuersaetze);
return {
categories: row.katList,
available: row.fVerfuegbar,
id: row.kArtikel,
stock: row.fBestand,
sku: row.cArtNr,
name: row.cName,
price: brutto == null ? null : Math.round(brutto * 10) / 10,
tax: taxRow?.fSteuersatz ?? null,
};
}
function buildTargetClause(request, target) {
const parts = [];
let i = 0;
const terms = target
.toString()
.trim()
.replace(/ {2,}/g, ' ')
.split(' ')
.filter(Boolean);
for (const part of terms) {
request.input(`target_${i}`, `%${part}%`);
parts.push(
'tGebinde.cEAN LIKE @target_' + i +
' OR tLiefArtikel.cLiefArtNr LIKE @target_' + i +
' OR tArtikelBeschreibung.cName LIKE @target_' + i +
' OR tArtikel.cArtNr LIKE @target_' + i +
' OR tArtikel.cBarcode LIKE @target_' + i
);
i++;
}
return { parts, nextIndex: i };
}
function buildCategoryClause(request, filter, startIndex) {
const parts = [];
let i = startIndex;
for (const part of filter) {
request.input(`target_${i}`, part);
parts.push(`tkategorieartikel.kKategorie = @target_${i}`);
i++;
}
return {
clause: '(' + parts.join(') OR (') + ')',
nextIndex: i,
};
}
function buildWhereClause(parts, filter) {
const aktivClause = filter?.[0] == '751' ? '' : " AND (tArtikel.cAktiv = 'Y')";
if (!parts.length) {
return `1=1${aktivClause}`;
}
return '(' + parts.join(') AND (') + ')' + aktivClause;
}
function buildQuery(filter, categoryFilter) {
const select = filter[0] === '' || filter[0] === null
? `SELECT TOP (2000) ${Q.SELECT_COLUMNS}`
: `SELECT ${Q.SELECT_COLUMNS}`;
const categoryJoin = categoryFilter
? Q.CATEGORY_JOIN.replace('{categoryFilter}', categoryFilter)
: '';
return `${select}
${Q.FROM_JOINS}
${categoryJoin}
WHERE `;
}
export async function searchArticles(target, filter = [''], offset = 0, maxCount = 10) {
const trimmed = target?.toString().trim();
if (!trimmed) {
return { categories: {}, length: 0, recordset: [] };
}
const start = Math.max(0, Number(offset) || 0);
const count = Math.max(0, Number(maxCount) || 10);
const pool = await getPool();
const request = pool.request();
const { parts } = buildTargetClause(request, trimmed);
const whereClause = buildWhereClause(parts, filter);
let sql = buildQuery(filter, null) + whereClause + Q.GROUP_ORDER;
if (filter[0] !== '' && filter[0] !== null) {
const { clause: categoryFilter } = buildCategoryClause(request, filter, parts.length);
sql = buildQuery(filter, categoryFilter) + whereClause + Q.GROUP_ORDER;
}
const result = await request.query(sql);
const [categoryData, steuersaetze] = await Promise.all([
loadCategoryCache(pool),
loadSteuersatz(),
]);
const categories = ensureFilterCategories(
shapeCategories(
buildKatAgg(result.recordset, categoryData.parentMap),
categoryData.byId
),
filter,
categoryData.byId,
categoryData.parentMap
);
const length = result.recordset.length;
const recordset = result.recordset
.slice(start, start + count)
.map((row) => shapeArticle(row, steuersaetze));
console.dir({ categories, length, recordset }, { depth: null });
return { categories, length, recordset };
}

View File

@@ -1,77 +0,0 @@
import { getPool } from '../../db/mssqlClient.js';
import {
CATEGORY_ARTICLE_COUNTS,
LOAD_CATEGORIES,
} from '../../db/mssql/categories.js';
function sortCategories(list) {
list.sort((a, b) => {
const sortDiff = (a.nSort ?? 0) - (b.nSort ?? 0);
if (sortDiff !== 0) {
return sortDiff;
}
return String(a.cName ?? '').localeCompare(String(b.cName ?? ''), 'de');
});
for (const node of list) {
sortCategories(node.children);
}
}
function buildCategoryTree(rows) {
const nodes = new Map();
for (const row of rows) {
nodes.set(row.kKategorie, {
kKategorie: row.kKategorie,
kOberKategorie: row.kOberKategorie,
nSort: row.nSort,
cName: row.cName ?? null,
count: row.count ?? 0,
children: [],
});
}
const roots = [];
for (const node of nodes.values()) {
const parent = nodes.get(node.kOberKategorie);
if (parent && node.kOberKategorie !== node.kKategorie) {
parent.children.push(node);
} else {
roots.push(node);
}
}
sortCategories(roots);
return roots;
}
/**
* Loads the full JTL category list with per-category article counts and a nested tree.
* @returns {Promise<{ categories: Array<{ kKategorie: number, kOberKategorie: number, nSort: number, cName: string|null, count: number }>, tree: object[] }>}
*/
export async function getCategories() {
const pool = await getPool();
const [categoriesResult, countsResult] = await Promise.all([
pool.request().query(LOAD_CATEGORIES),
pool.request().query(CATEGORY_ARTICLE_COUNTS),
]);
const countByCategory = new Map(
countsResult.recordset.map((row) => [row.kKategorie, row.count])
);
const categories = categoriesResult.recordset.map((row) => ({
kKategorie: row.kKategorie,
kOberKategorie: row.kOberKategorie,
nSort: row.nSort,
cName: row.cName ?? null,
count: countByCategory.get(row.kKategorie) ?? 0,
}));
return {
categories,
tree: buildCategoryTree(categories),
};
}

View File

@@ -1,63 +0,0 @@
import { getPool } from '../../db/mssqlClient.js';
import { LOAD_STEUERSATZ, LOAD_STEUERKLASSEN } from '../../db/mssql/steuer.js';
import env from '../../config/env.js';
let steuersatzCache;
let steuerklassenCache;
/**
* Loads VAT rates per tax class for the configured steuer zone (`tSteuersatz` only).
* Results are cached for the process lifetime.
* @returns {Promise<Array<{ kSteuerklasse: number, fSteuersatz: number }>>}
*/
export async function loadSteuersatz() {
if (steuersatzCache) {
return steuersatzCache;
}
const pool = await getPool();
const result = await pool.request()
.input('steuerZone', env.steuerZone)
.query(LOAD_STEUERSATZ);
steuersatzCache = result.recordset.map((row) => ({
kSteuerklasse: row.kSteuerklasse,
fSteuersatz: row.fSteuersatz,
}));
return steuersatzCache;
}
/**
* Loads tax classes with display metadata for the configured steuer zone.
* Results are cached for the process lifetime.
* @returns {Promise<Array<{ kSteuerklasse: number, cName: string, nStandard: number, fSteuersatz: number }>>}
*/
export async function loadSteuerklassen() {
if (steuerklassenCache) {
return steuerklassenCache;
}
const pool = await getPool();
const result = await pool.request()
.input('steuerZone', env.steuerZone)
.query(LOAD_STEUERKLASSEN);
steuerklassenCache = result.recordset.map((row) => ({
kSteuerklasse: row.kSteuerklasse,
cName: row.cName,
nStandard: row.nStandard,
fSteuersatz: row.fSteuersatz,
}));
return steuerklassenCache;
}
export function bruttoFromNetto(netto, kSteuerklasse, steuersaetze) {
const row = steuersaetze.find((item) => item.kSteuerklasse === kSteuerklasse);
if (row == null || netto == null) {
return null;
}
return netto * (1 + row.fSteuersatz / 100);
}
export async function getSteuer() {
const steuerklassen = await loadSteuerklassen();
return { steuerklassen };
}