security: fix all Critical and High findings from FLAWS_REPORT audit
- Auth: pessimistic locking on login tokens and refresh token rotation, backup code attempt counter, rate limiting verification - Schema: unique constraints on business numbers, FK relations, unsigned/signed alignment, attendance duplicate prevention - Invoices/PDFs: DOMPurify sanitization, bounded queries in stats and alerts, VAT rounding, Puppeteer error handling - Orders/Offers: transactional parent+child creation, Zod NaN refinement, status enums, uniqueness checks - Projects/Files: path traversal protection, streamed uploads, permission guards, query param validation - Attendance/HR: duplicate checks, ownership validation, GPS restrictions, trip distance validation - Frontend: modal lock reference counting, XSS escaping in print HTML, ref mutation fixes, accessibility attributes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
533
package-lock.json
generated
533
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.3",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -19,6 +19,7 @@
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.3",
|
||||
@@ -27,6 +28,7 @@
|
||||
"file-type": "^16.5.4",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hi-base32": "^0.5.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -64,6 +66,53 @@
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -87,6 +136,152 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
@@ -630,6 +825,23 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/accept-negotiator": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
|
||||
@@ -1513,6 +1725,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsdom": {
|
||||
"version": "28.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz",
|
||||
"integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/tough-cookie": "*",
|
||||
"parse5": "^7.0.0",
|
||||
"undici-types": "^7.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsdom/node_modules/undici-types": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz",
|
||||
"integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
@@ -1562,7 +1792,6 @@
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -1639,6 +1868,12 @@
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -2088,6 +2323,15 @@
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
@@ -2494,6 +2738,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2510,6 +2767,19 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
@@ -2546,6 +2816,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
@@ -2721,6 +2997,18 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -3496,6 +3784,18 @@
|
||||
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -3617,6 +3917,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -3644,6 +3950,70 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
|
||||
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.5",
|
||||
"@asamuzakjp/dom-selector": "^7.0.6",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.7",
|
||||
"parse5": "^8.0.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.24.5",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/json-parse-even-better-errors": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
@@ -4142,6 +4512,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
@@ -4477,6 +4853,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4741,6 +5129,15 @@
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer": {
|
||||
"version": "24.40.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz",
|
||||
@@ -5257,6 +5654,18 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -5469,7 +5878,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5635,6 +6043,12 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||
@@ -5768,6 +6182,24 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.28",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz",
|
||||
"integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.28"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.0.28",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz",
|
||||
"integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/toad-cache": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||
@@ -5803,6 +6235,30 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
@@ -5859,11 +6315,19 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
@@ -6027,12 +6491,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
|
||||
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
@@ -6100,6 +6608,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.3",
|
||||
@@ -42,6 +43,7 @@
|
||||
"file-type": "^16.5.4",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hi-base32": "^0.5.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"node-cron": "^4.2.1",
|
||||
|
||||
@@ -32,7 +32,7 @@ model attendance {
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
|
||||
attendance_project_logs attendance_project_logs[]
|
||||
|
||||
@@index([user_id, shift_date], map: "idx_attendance_user_date")
|
||||
@@unique([user_id, shift_date], map: "idx_attendance_user_date")
|
||||
@@index([user_id, departure_time], map: "idx_attendance_user_departure")
|
||||
@@index([project_id], map: "idx_project_id")
|
||||
}
|
||||
@@ -46,6 +46,7 @@ model attendance_project_logs {
|
||||
hours Int? @db.UnsignedInt
|
||||
minutes Int? @db.UnsignedInt
|
||||
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
|
||||
@@index([attendance_id], map: "idx_attendance_project_logs_aid")
|
||||
@@index([project_id], map: "idx_project_id")
|
||||
@@ -104,7 +105,7 @@ model company_settings {
|
||||
quotation_prefix String? @db.VarChar(20)
|
||||
default_currency String? @default("CZK") @db.VarChar(10)
|
||||
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
uuid String? @db.VarChar(36)
|
||||
uuid String? @unique @db.VarChar(36)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
is_deleted Boolean? @default(false)
|
||||
sync_version Int? @default(0)
|
||||
@@ -165,7 +166,7 @@ model invoice_items {
|
||||
|
||||
model invoices {
|
||||
id Int @id @default(autoincrement())
|
||||
invoice_number String? @db.VarChar(50)
|
||||
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
|
||||
order_id Int?
|
||||
customer_id Int?
|
||||
status String? @default("issued") @db.VarChar(30)
|
||||
@@ -288,7 +289,7 @@ model order_sections {
|
||||
|
||||
model orders {
|
||||
id Int @id @default(autoincrement())
|
||||
order_number String? @db.VarChar(50)
|
||||
order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50)
|
||||
customer_order_number String? @db.VarChar(100)
|
||||
attachment_data Bytes?
|
||||
attachment_name String? @db.VarChar(255)
|
||||
@@ -340,7 +341,7 @@ model project_notes {
|
||||
|
||||
model projects {
|
||||
id Int @id @default(autoincrement())
|
||||
project_number String? @db.VarChar(50)
|
||||
project_number String? @unique @db.VarChar(50)
|
||||
name String? @db.VarChar(255)
|
||||
customer_id Int?
|
||||
responsible_user_id Int?
|
||||
@@ -352,6 +353,7 @@ model projects {
|
||||
notes String? @db.Text
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
attendance_project_logs attendance_project_logs[]
|
||||
project_notes project_notes[]
|
||||
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
|
||||
@@ -385,7 +387,7 @@ model quotation_items {
|
||||
|
||||
model quotations {
|
||||
id Int @id @default(autoincrement())
|
||||
quotation_number String? @db.VarChar(50)
|
||||
quotation_number String? @unique @db.VarChar(50)
|
||||
project_code String? @db.VarChar(50)
|
||||
customer_id Int?
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
@@ -434,7 +436,7 @@ model received_invoices {
|
||||
file_mime String? @db.VarChar(100)
|
||||
file_size Int? @db.UnsignedInt
|
||||
notes String? @db.Text
|
||||
uploaded_by Int? @db.UnsignedInt
|
||||
uploaded_by Int?
|
||||
created_at DateTime @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime @default(now()) @db.DateTime(0)
|
||||
|
||||
@@ -446,7 +448,7 @@ model received_invoices {
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model refresh_tokens {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
user_id Int @db.UnsignedInt
|
||||
user_id Int
|
||||
token_hash String @unique(map: "token_hash") @db.VarChar(64)
|
||||
expires_at DateTime @db.DateTime(0)
|
||||
replaced_at DateTime? @db.DateTime(0)
|
||||
|
||||
@@ -91,7 +91,7 @@ function NativeInput({
|
||||
}
|
||||
|
||||
interface AdminDatePickerProps {
|
||||
mode?: "date" | "month" | "datetime" | "time";
|
||||
mode?: "date" | "month" | "time";
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
minDate?: string;
|
||||
|
||||
@@ -57,13 +57,21 @@ export default function BulkAttendanceModal({
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-attendance-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
|
||||
<h2
|
||||
id="bulk-attendance-modal-title"
|
||||
className="admin-modal-title"
|
||||
>
|
||||
Vyplnit docházku za měsíc
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
|
||||
@@ -39,6 +39,9 @@ export default function ConfirmModal({
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className="admin-modal admin-confirm-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
@@ -59,7 +62,9 @@ export default function ConfirmModal({
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="admin-confirm-title">{title}</h2>
|
||||
<h2 id="confirm-modal-title" className="admin-confirm-title">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="admin-confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
|
||||
@@ -109,13 +109,19 @@ export default function OrderConfirmationModal({
|
||||
className={
|
||||
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="order-confirmation-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">
|
||||
<h2
|
||||
id="order-confirmation-modal-title"
|
||||
className="admin-modal-title"
|
||||
>
|
||||
Potvrzení objednávky {orderNumber}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -197,6 +197,7 @@ export default function ProjectFileManager({
|
||||
}: ProjectFileManagerProps) {
|
||||
const alert = useAlert();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isCancelling = useRef(false);
|
||||
|
||||
const [items, setItems] = useState<FileItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -768,10 +769,26 @@ export default function ProjectFileManager({
|
||||
}}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename(item);
|
||||
if (e.key === "Escape") setRenamingItem(null);
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRename(item);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
isCancelling.current = true;
|
||||
setRenamingItem(null);
|
||||
setRenameValue(item.name);
|
||||
setTimeout(() => {
|
||||
isCancelling.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isCancelling.current) {
|
||||
return;
|
||||
}
|
||||
handleRename(item);
|
||||
}}
|
||||
onBlur={() => handleRename(item)}
|
||||
/>
|
||||
) : (
|
||||
<FileNameCell
|
||||
|
||||
@@ -251,13 +251,16 @@ export default function ShiftFormModal({
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shift-form-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">
|
||||
<h2 id="shift-form-modal-title" className="admin-modal-title">
|
||||
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||
</h2>
|
||||
{!isCreate && editingRecord && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
@@ -39,6 +40,15 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const counterRef = useRef(0);
|
||||
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutsRef.current.forEach(clearTimeout);
|
||||
timeoutsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addAlert = useCallback(
|
||||
(message: string, type = "success", duration = 4000) => {
|
||||
const id = `${Date.now()}-${counterRef.current++}`;
|
||||
@@ -47,7 +57,8 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
||||
{ id, message, type: type as Alert["type"] },
|
||||
]);
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeAlert(id), duration);
|
||||
const timeoutId = setTimeout(() => removeAlert(id), duration);
|
||||
timeoutsRef.current.add(timeoutId);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
@@ -84,32 +84,35 @@ function mapUser(u: Record<string, unknown> | null): User | null {
|
||||
} as User;
|
||||
}
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let tokenExpiresAt: number | null = null;
|
||||
let cachedUser: User | null = null;
|
||||
let sessionFetched = false;
|
||||
let silentRefreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(cachedUser);
|
||||
const [loading, setLoading] = useState(!sessionFetched);
|
||||
const accessTokenRef = useRef<string | null>(null);
|
||||
const tokenExpiresAtRef = useRef<number | null>(null);
|
||||
const cachedUserRef = useRef<User | null>(null);
|
||||
const sessionFetchedRef = useRef(false);
|
||||
const silentRefreshInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const [user, setUser] = useState<User | null>(cachedUserRef.current);
|
||||
const [loading, setLoading] = useState(!sessionFetchedRef.current);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cachedUser = user;
|
||||
cachedUserRef.current = user;
|
||||
}, [user]);
|
||||
|
||||
const getAccessTokenFn = useCallback((): string | null => {
|
||||
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null;
|
||||
return accessToken;
|
||||
if (
|
||||
!tokenExpiresAtRef.current ||
|
||||
Date.now() > tokenExpiresAtRef.current - 30000
|
||||
)
|
||||
return null;
|
||||
return accessTokenRef.current;
|
||||
}, []);
|
||||
|
||||
const setAccessTokenFn = useCallback(
|
||||
(token: string | null, expiresIn?: number) => {
|
||||
const ttl = expiresIn ?? 900; // default 15 min matching backend config
|
||||
accessToken = token;
|
||||
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null;
|
||||
accessTokenRef.current = token;
|
||||
tokenExpiresAtRef.current = token ? Date.now() + ttl * 1000 : null;
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = null;
|
||||
@@ -126,7 +129,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const silentRefresh = useCallback(async (): Promise<boolean> => {
|
||||
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
||||
if (silentRefreshInFlight) return silentRefreshInFlight;
|
||||
if (silentRefreshInFlightRef.current)
|
||||
return silentRefreshInFlightRef.current;
|
||||
|
||||
const promise = (async (): Promise<boolean> => {
|
||||
try {
|
||||
@@ -140,21 +144,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(mapUser(data.data.user));
|
||||
return true;
|
||||
}
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
accessTokenRef.current = null;
|
||||
tokenExpiresAtRef.current = null;
|
||||
setUser(null);
|
||||
cachedUser = null;
|
||||
cachedUserRef.current = null;
|
||||
setSessionExpired();
|
||||
return false;
|
||||
} catch {
|
||||
// Network error — don't kick the user out, just return false
|
||||
return false;
|
||||
} finally {
|
||||
silentRefreshInFlight = null;
|
||||
silentRefreshInFlightRef.current = null;
|
||||
}
|
||||
})();
|
||||
|
||||
silentRefreshInFlight = promise;
|
||||
silentRefreshInFlightRef.current = promise;
|
||||
return promise;
|
||||
}, [setAccessTokenFn]);
|
||||
|
||||
@@ -172,12 +176,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
headers,
|
||||
});
|
||||
if (response.status === 429 || response.status >= 500)
|
||||
return !!cachedUser;
|
||||
return !!cachedUserRef.current;
|
||||
const data = await response.json();
|
||||
if (data.success && data.data?.user) {
|
||||
if (data.data.access_token) setAccessTokenFn(data.data.access_token);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUser = mapUser(data.data.user);
|
||||
cachedUserRef.current = mapUser(data.data.user);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -185,15 +189,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const refreshed = await silentRefresh();
|
||||
if (refreshed) return true;
|
||||
setUser(null);
|
||||
cachedUser = null;
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
cachedUserRef.current = null;
|
||||
accessTokenRef.current = null;
|
||||
tokenExpiresAtRef.current = null;
|
||||
return false;
|
||||
} catch {
|
||||
return !!cachedUser;
|
||||
return !!cachedUserRef.current;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
sessionFetched = true;
|
||||
sessionFetchedRef.current = true;
|
||||
}
|
||||
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
|
||||
|
||||
@@ -231,8 +235,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUser = mapUser(data.data.user);
|
||||
sessionFetched = true;
|
||||
cachedUserRef.current = mapUser(data.data.user);
|
||||
sessionFetchedRef.current = true;
|
||||
return { success: true };
|
||||
}
|
||||
setError(data.error);
|
||||
@@ -270,8 +274,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (data.success) {
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUser = mapUser(data.data.user);
|
||||
sessionFetched = true;
|
||||
cachedUserRef.current = mapUser(data.data.user);
|
||||
sessionFetchedRef.current = true;
|
||||
return { success: true };
|
||||
}
|
||||
setError(data.error);
|
||||
@@ -296,11 +300,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
accessTokenRef.current = null;
|
||||
tokenExpiresAtRef.current = null;
|
||||
setUser(null);
|
||||
cachedUser = null;
|
||||
sessionFetched = false;
|
||||
cachedUserRef.current = null;
|
||||
sessionFetchedRef.current = false;
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = null;
|
||||
|
||||
@@ -23,8 +23,9 @@ export default function useApiCall() {
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const { signal: _, ...restOptions } = options;
|
||||
const response = await apiFetch(url, {
|
||||
...options,
|
||||
...restOptions,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -224,11 +224,20 @@ function computeUserTotals(
|
||||
// Print helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function renderFundStatus(userData: Record<string, any>): string {
|
||||
if (userData.overtime > 0)
|
||||
return `<span class="leave-badge badge-overtime">+${userData.overtime}h přesčas</span>`;
|
||||
return `<span class="leave-badge badge-overtime">+${escapeHtml(String(userData.overtime))}h přesčas</span>`;
|
||||
if (userData.missing > 0)
|
||||
return `<span style="color:#dc2626">−${userData.missing}h</span>`;
|
||||
return `<span style="color:#dc2626">−${escapeHtml(String(userData.missing))}h</span>`;
|
||||
return '<span style="color:#16a34a">splněno</span>';
|
||||
}
|
||||
|
||||
@@ -255,11 +264,11 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
|
||||
h = 0;
|
||||
m = 0;
|
||||
}
|
||||
return `<div>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h)</div>`;
|
||||
return `<div>${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return record.project_name || "—";
|
||||
return escapeHtml(record.project_name || "—");
|
||||
}
|
||||
|
||||
function buildLeaveSummaryHtml(
|
||||
@@ -268,15 +277,15 @@ function buildLeaveSummaryHtml(
|
||||
printData: Record<string, any>,
|
||||
): string {
|
||||
const bal = printData.leave_balances[userId];
|
||||
let parts = `<strong>Dovolená ${printData.year}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`;
|
||||
let parts = `<strong>Dovolená ${escapeHtml(String(printData.year))}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`;
|
||||
if (userData.vacation_hours > 0)
|
||||
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${userData.vacation_hours}h</span>`;
|
||||
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${escapeHtml(String(userData.vacation_hours))}h</span>`;
|
||||
if (userData.sick_hours > 0)
|
||||
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${userData.sick_hours}h</span>`;
|
||||
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${escapeHtml(String(userData.sick_hours))}h</span>`;
|
||||
if (userData.holiday_hours > 0)
|
||||
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${userData.holiday_hours}h</span>`;
|
||||
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${escapeHtml(String(userData.holiday_hours))}h</span>`;
|
||||
if (userData.overtime > 0)
|
||||
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${userData.overtime}h</span>`;
|
||||
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${escapeHtml(String(userData.overtime))}h</span>`;
|
||||
return `<div class="leave-summary">${parts}</div>`;
|
||||
}
|
||||
|
||||
@@ -299,17 +308,17 @@ function buildUserSectionHtml(
|
||||
const breakCell =
|
||||
isLeave || !record.break_start || !record.break_end
|
||||
? "—"
|
||||
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`;
|
||||
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
|
||||
|
||||
return `<tr>
|
||||
<td>${formatDate(record.shift_date)}</td>
|
||||
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td>
|
||||
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
|
||||
<td>${escapeHtml(formatDate(record.shift_date))}</td>
|
||||
<td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></td>
|
||||
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))}</td>
|
||||
<td class="text-center">${breakCell}</td>
|
||||
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
|
||||
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
|
||||
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td>
|
||||
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
|
||||
<td>${record.notes || ""}</td>
|
||||
<td>${escapeHtml(record.notes || "")}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -318,15 +327,15 @@ function buildUserSectionHtml(
|
||||
userData.fund !== null
|
||||
? `<tr>
|
||||
<td colspan="6" class="text-right">Fond měsíce:</td>
|
||||
<td class="text-center">${userData.covered}h / ${userData.fund}h</td>
|
||||
<td class="text-center">${escapeHtml(String(userData.covered))}h / ${escapeHtml(String(userData.fund))}h</td>
|
||||
<td colspan="2">${renderFundStatus(userData)}</td>
|
||||
</tr>`
|
||||
: "";
|
||||
|
||||
return `<div class="user-section">
|
||||
<div class="user-header">
|
||||
<h3>${userData.name}</h3>
|
||||
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span>
|
||||
<h3>${escapeHtml(userData.name)}</h3>
|
||||
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
|
||||
</div>
|
||||
${leaveHtml}
|
||||
<table>
|
||||
@@ -344,7 +353,7 @@ function buildUserSectionHtml(
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="6" class="text-right">Odpracováno:</td>
|
||||
<td class="text-center">${formatMinutes(userData.minutes)} h</td>
|
||||
<td class="text-center">${escapeHtml(formatMinutes(userData.minutes))} h</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
${fundRow}
|
||||
@@ -365,7 +374,7 @@ function buildPrintHtml(
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Docházka - ${pData.month_name}</title>
|
||||
<title>Docházka - ${escapeHtml(pData.month_name)}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
@@ -428,11 +437,11 @@ function buildPrintHtml(
|
||||
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
|
||||
<div class="print-header-text">
|
||||
<h1>EVIDENCE DOCHÁZKY</h1>
|
||||
<div class="company">${companyName}</div>
|
||||
<div class="company">${escapeHtml(companyName)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="print-header-right">
|
||||
<div class="period">${pData.month_name}</div>
|
||||
<div class="period">${escapeHtml(pData.month_name)}</div>
|
||||
${filterNote}
|
||||
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
|
||||
</div>
|
||||
@@ -1037,7 +1046,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
|
||||
: "";
|
||||
const filterNote = pData.selected_user_name
|
||||
? `<div class="filters">Zaměstnanec: ${pData.selected_user_name}</div>`
|
||||
? `<div class="filters">Zaměstnanec: ${escapeHtml(pData.selected_user_name)}</div>`
|
||||
: "";
|
||||
const bodyContent = buildPrintHtml(
|
||||
pData,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
let activeLocks = 0;
|
||||
|
||||
export default function useModalLock(isOpen: boolean): void {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
if (activeLocks === 0) document.body.style.overflow = "hidden";
|
||||
activeLocks++;
|
||||
return () => {
|
||||
activeLocks = Math.max(0, activeLocks - 1);
|
||||
if (activeLocks === 0) document.body.style.overflow = "";
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ export default function InvoiceDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const keyCounterRef = useRef(0);
|
||||
const keyCounterRef = useRef(1);
|
||||
const emptyItem = useCallback(
|
||||
(): InvoiceItem => ({
|
||||
_key: `inv-${++keyCounterRef.current}`,
|
||||
@@ -369,7 +369,16 @@ export default function InvoiceDetail() {
|
||||
|
||||
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
||||
const [dueDays, setDueDays] = useState(14);
|
||||
const [items, setItems] = useState<InvoiceItem[]>([emptyItem()]);
|
||||
const [items, setItems] = useState<InvoiceItem[]>([
|
||||
{
|
||||
_key: "inv-1",
|
||||
description: "",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
vat_rate: 21,
|
||||
},
|
||||
]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -138,8 +138,18 @@ export default function Invoices() {
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const slideDirection = useRef(0);
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
const [slideKey, setSlideKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isCurrentMonth =
|
||||
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
|
||||
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
|
||||
@@ -299,9 +309,11 @@ export default function Invoices() {
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
}
|
||||
blobUrlRef.current = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = blobUrlRef.current;
|
||||
} catch {
|
||||
alert.error("Chyba při generování PDF");
|
||||
} finally {
|
||||
|
||||
@@ -55,9 +55,6 @@ interface OfferItem {
|
||||
is_included_in_total: boolean;
|
||||
}
|
||||
|
||||
let _itemKeyCounter = 0;
|
||||
const nextItemKey = () => `item-${++_itemKeyCounter}`;
|
||||
|
||||
interface ScopeSection {
|
||||
title: string;
|
||||
title_cz: string;
|
||||
@@ -113,16 +110,6 @@ const emptyScopeSection = (): ScopeSection => ({
|
||||
content: "",
|
||||
});
|
||||
|
||||
const emptyItem = (): OfferItem => ({
|
||||
_key: nextItemKey(),
|
||||
description: "",
|
||||
item_description: "",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
is_included_in_total: true,
|
||||
});
|
||||
|
||||
function SortableItemRow({
|
||||
item,
|
||||
index,
|
||||
@@ -288,11 +275,25 @@ export default function OfferDetail() {
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const itemKeyCounter = useRef(0);
|
||||
const emptyItem = useCallback(
|
||||
(): OfferItem => ({
|
||||
_key: `item-${++itemKeyCounter.current}`,
|
||||
description: "",
|
||||
item_description: "",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
is_included_in_total: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||
const [form, setForm] = useState<OfferForm>(emptyForm);
|
||||
const [items, setItems] = useState<OfferItem[]>([emptyItem()]);
|
||||
const [items, setItems] = useState<OfferItem[]>(() => [emptyItem()]);
|
||||
const [sections, setSections] = useState<ScopeSection[]>([]);
|
||||
const [scopeTemplates, setScopeTemplates] = useState<
|
||||
Array<{
|
||||
@@ -397,7 +398,10 @@ export default function OfferDetail() {
|
||||
});
|
||||
setItems(
|
||||
d.items?.length
|
||||
? d.items.map((it: any) => ({ ...it, _key: nextItemKey() }))
|
||||
? d.items.map((it: any) => ({
|
||||
...it,
|
||||
_key: `item-${++itemKeyCounter.current}`,
|
||||
}))
|
||||
: [emptyItem()],
|
||||
);
|
||||
setSections(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@@ -63,6 +63,16 @@ export default function Offers() {
|
||||
quotation: Quotation | null;
|
||||
}>({ show: false, quotation: null });
|
||||
const [invalidating, setInvalidating] = useState(false);
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const [duplicating, setDuplicating] = useState<number | null>(null);
|
||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
||||
const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
|
||||
@@ -237,9 +247,11 @@ export default function Offers() {
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
}
|
||||
blobUrlRef.current = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = blobUrlRef.current;
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function Settings() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [users, setUsers] = useState<{ role_id: number }[]>([]);
|
||||
const [, setAllPermissions] = useState<Permission[]>([]);
|
||||
const [permissionGroups, setPermissionGroups] = useState<
|
||||
Record<string, Permission[]>
|
||||
@@ -161,12 +162,14 @@ export default function Settings() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [rolesRes, permsRes] = await Promise.all([
|
||||
const [rolesRes, permsRes, usersRes] = await Promise.all([
|
||||
apiFetch(`${API_BASE}/roles`),
|
||||
apiFetch(`${API_BASE}/roles/permissions`),
|
||||
apiFetch(`${API_BASE}/users`),
|
||||
]);
|
||||
const rolesResult = await rolesRes.json();
|
||||
const permsResult = await permsRes.json();
|
||||
const usersResult = await usersRes.json();
|
||||
|
||||
if (rolesResult.success) {
|
||||
setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []);
|
||||
@@ -188,6 +191,10 @@ export default function Settings() {
|
||||
}
|
||||
setPermissionGroups(groups);
|
||||
}
|
||||
|
||||
if (usersResult.success) {
|
||||
setUsers(Array.isArray(usersResult.data) ? usersResult.data : []);
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
@@ -808,7 +815,7 @@ export default function Settings() {
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-badge admin-badge-secondary">
|
||||
{0}
|
||||
{users.filter((u) => u.role_id === role.id).length}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@@ -838,16 +845,21 @@ export default function Settings() {
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title={
|
||||
0 > 0
|
||||
users.filter((u) => u.role_id === role.id)
|
||||
.length > 0
|
||||
? "Nelze smazat roli s přiřazenými uživateli"
|
||||
: "Smazat"
|
||||
}
|
||||
aria-label={
|
||||
0 > 0
|
||||
users.filter((u) => u.role_id === role.id)
|
||||
.length > 0
|
||||
? "Nelze smazat roli s přiřazenými uživateli"
|
||||
: "Smazat"
|
||||
}
|
||||
disabled={0 > 0}
|
||||
disabled={
|
||||
users.filter((u) => u.role_id === role.id)
|
||||
.length > 0
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
|
||||
@@ -1,39 +1,55 @@
|
||||
let showSessionExpiredAlert = false;
|
||||
let showLogoutAlert = false;
|
||||
let getTokenFn: (() => string | null) | null = null;
|
||||
let refreshFn: (() => Promise<boolean>) | null = null;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
class ApiState {
|
||||
showSessionExpiredAlert = false;
|
||||
showLogoutAlert = false;
|
||||
getTokenFn: (() => string | null) | null = null;
|
||||
refreshFn: (() => Promise<boolean>) | null = null;
|
||||
refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
reset() {
|
||||
this.showSessionExpiredAlert = false;
|
||||
this.showLogoutAlert = false;
|
||||
this.getTokenFn = null;
|
||||
this.refreshFn = null;
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
const state = new ApiState();
|
||||
|
||||
export const resetApiState = (): void => {
|
||||
state.reset();
|
||||
};
|
||||
|
||||
export const shouldShowSessionExpiredAlert = (): boolean => {
|
||||
if (showSessionExpiredAlert) {
|
||||
showSessionExpiredAlert = false;
|
||||
if (state.showSessionExpiredAlert) {
|
||||
state.showSessionExpiredAlert = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setSessionExpired = (): void => {
|
||||
showSessionExpiredAlert = true;
|
||||
state.showSessionExpiredAlert = true;
|
||||
};
|
||||
|
||||
export const shouldShowLogoutAlert = (): boolean => {
|
||||
if (showLogoutAlert) {
|
||||
showLogoutAlert = false;
|
||||
if (state.showLogoutAlert) {
|
||||
state.showLogoutAlert = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setLogoutAlert = (): void => {
|
||||
showLogoutAlert = true;
|
||||
state.showLogoutAlert = true;
|
||||
};
|
||||
|
||||
export const setTokenGetter = (fn: () => string | null): void => {
|
||||
getTokenFn = fn;
|
||||
state.getTokenFn = fn;
|
||||
};
|
||||
|
||||
export const setRefreshFn = (fn: () => Promise<boolean>): void => {
|
||||
refreshFn = fn;
|
||||
state.refreshFn = fn;
|
||||
};
|
||||
|
||||
export const apiFetch = async (
|
||||
@@ -42,7 +58,7 @@ export const apiFetch = async (
|
||||
): Promise<Response> => {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = getTokenFn ? getTokenFn() : null;
|
||||
token = state.getTokenFn ? state.getTokenFn() : null;
|
||||
} catch {
|
||||
// token retrieval failed
|
||||
}
|
||||
@@ -69,21 +85,22 @@ export const apiFetch = async (
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.status === 401 && refreshFn) {
|
||||
if (response.status === 401 && state.refreshFn) {
|
||||
try {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshFn().finally(() => {
|
||||
refreshPromise = null;
|
||||
if (!state.refreshPromise) {
|
||||
state.refreshPromise = state.refreshFn().finally(() => {
|
||||
state.refreshPromise = null;
|
||||
});
|
||||
}
|
||||
const refreshed = await refreshPromise;
|
||||
const refreshed = await state.refreshPromise;
|
||||
if (refreshed) {
|
||||
token = getTokenFn ? getTokenFn() : null;
|
||||
token = state.getTokenFn ? state.getTokenFn() : null;
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const { signal, ...retryOptions } = options;
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
...retryOptions,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
@@ -100,7 +117,7 @@ export const apiFetch = async (
|
||||
|
||||
export const getAccessToken = (): string | null => {
|
||||
try {
|
||||
return getTokenFn ? getTokenFn() : null;
|
||||
return state.getTokenFn ? state.getTokenFn() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,14 @@ export const config = {
|
||||
path: process.env.NAS_PATH || "Z:/02_PROJEKTY",
|
||||
financialsPath: process.env.NAS_FINANCIALS_PATH || "",
|
||||
offersPath: process.env.NAS_OFFERS_PATH || "",
|
||||
maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || "52428800", 10),
|
||||
maxUploadSize: (() => {
|
||||
const parsed = parseInt(process.env.MAX_UPLOAD_SIZE || "52428800", 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
console.warn("Invalid MAX_UPLOAD_SIZE, using default 52428800");
|
||||
return 52428800;
|
||||
}
|
||||
return parsed;
|
||||
})(),
|
||||
},
|
||||
|
||||
email: {
|
||||
|
||||
@@ -100,6 +100,9 @@ export default async function attendanceRoutes(
|
||||
|
||||
// --- action=balances: leave balance overview for all users ---
|
||||
if (action === "balances") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getBalances(yr);
|
||||
return reply.send({ success: true, data });
|
||||
@@ -107,6 +110,9 @@ export default async function attendanceRoutes(
|
||||
|
||||
// --- action=workfund: monthly work fund overview ---
|
||||
if (action === "workfund") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getWorkfund(yr);
|
||||
return reply.send({ success: true, data });
|
||||
@@ -114,6 +120,9 @@ export default async function attendanceRoutes(
|
||||
|
||||
// --- action=project_report: monthly project hours ---
|
||||
if (action === "project_report") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getProjectReport(yr);
|
||||
return reply.send({ success: true, data });
|
||||
@@ -185,6 +194,10 @@ export default async function attendanceRoutes(
|
||||
if (!id) return error(reply, "Missing id", 400);
|
||||
const record = await attendanceService.getLocationRecord(id);
|
||||
if (!record) return error(reply, "Záznam nenalezen", 404);
|
||||
const isAdmin = authData.permissions.includes("attendance.admin");
|
||||
if (record.user_id !== authData.userId && !isAdmin) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
return reply.send({ success: true, data: record });
|
||||
}
|
||||
|
||||
@@ -294,6 +307,14 @@ export default async function attendanceRoutes(
|
||||
if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400);
|
||||
const leaveBody = leaveParsed.data;
|
||||
|
||||
if (
|
||||
leaveBody.user_id != null &&
|
||||
leaveBody.user_id !== authData.userId &&
|
||||
!authData.permissions.includes("attendance.admin")
|
||||
) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
|
||||
const result = await attendanceService.createLeave(
|
||||
{
|
||||
user_id: leaveBody.user_id,
|
||||
@@ -342,6 +363,14 @@ export default async function attendanceRoutes(
|
||||
if ("error" in stdParsed) return error(reply, stdParsed.error, 400);
|
||||
const body = stdParsed.data;
|
||||
|
||||
if (
|
||||
body.user_id != null &&
|
||||
body.user_id !== authData.userId &&
|
||||
!authData.permissions.includes("attendance.admin")
|
||||
) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
|
||||
const result = await attendanceService.createAttendance(
|
||||
{
|
||||
user_id: body.user_id,
|
||||
@@ -364,6 +393,8 @@ export default async function attendanceRoutes(
|
||||
},
|
||||
authData.userId,
|
||||
);
|
||||
if ("error" in result)
|
||||
return error(reply, result.error!, result.status ?? 400);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, paginated, error } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
|
||||
@@ -53,6 +54,13 @@ export default async function auditLogRoutes(
|
||||
// days === 0 means "delete all" (from frontend "Vše" option)
|
||||
if (days === 0 || body.action === "all") {
|
||||
const result = await prisma.audit_logs.deleteMany({});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "audit_logs",
|
||||
description: `Uživatel ${request.authData?.username ?? "unknown"} smazal všechny audit logy, počet: ${result.count}`,
|
||||
});
|
||||
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
|
||||
}
|
||||
|
||||
@@ -62,6 +70,13 @@ export default async function auditLogRoutes(
|
||||
const result = await prisma.audit_logs.deleteMany({
|
||||
where: { created_at: { lt: cutoff } },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "audit_logs",
|
||||
description: `Uživatel ${request.authData?.username ?? "unknown"} smazal audit logy starší než ${days} dní, počet: ${result.count}`,
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
null,
|
||||
|
||||
@@ -92,7 +92,15 @@ export default async function authRoutes(
|
||||
// POST /api/admin/login/totp
|
||||
fastify.post<{ Body: TotpVerifyRequest }>(
|
||||
"/login/totp",
|
||||
{ bodyLimit: 10240 },
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 20,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpVerifySchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
@@ -106,20 +114,42 @@ export default async function authRoutes(
|
||||
.update(login_token)
|
||||
.digest("hex");
|
||||
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
const totpResult = await prisma.$transaction(async (tx) => {
|
||||
const tokens = await tx.$queryRaw<
|
||||
Array<{ id: number; user_id: number; expires_at: Date }>
|
||||
>`
|
||||
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
|
||||
`;
|
||||
const storedToken = tokens[0] ?? null;
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return { error: "Neplatný nebo expirovaný login token", status: 401 };
|
||||
}
|
||||
|
||||
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
const user = await tx.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_secret) {
|
||||
return { error: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
return { error: "Účet je dočasně uzamčen", status: 429 };
|
||||
}
|
||||
|
||||
return { user };
|
||||
});
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, "Neplatný nebo expirovaný login token", 401);
|
||||
if ("error" in totpResult) {
|
||||
return error(reply, totpResult.error!, totpResult.status!);
|
||||
}
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_secret) {
|
||||
const user = totpResult.user;
|
||||
if (!user.totp_secret) {
|
||||
return error(reply, "Uživatel nenalezen", 401);
|
||||
}
|
||||
|
||||
@@ -128,8 +158,6 @@ export default async function authRoutes(
|
||||
return error(reply, "Neplatný TOTP kód", 401);
|
||||
}
|
||||
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
@@ -186,31 +214,43 @@ export default async function authRoutes(
|
||||
);
|
||||
|
||||
// POST /api/admin/refresh
|
||||
fastify.post("/refresh", { bodyLimit: 10240 }, async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (!refreshTokenRaw) {
|
||||
return error(reply, "Refresh token chybí", 401);
|
||||
}
|
||||
fastify.post(
|
||||
"/refresh",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (!refreshTokenRaw) {
|
||||
return error(reply, "Refresh token chybí", 401);
|
||||
}
|
||||
|
||||
const result = await refreshAccessToken(refreshTokenRaw, request);
|
||||
const result = await refreshAccessToken(refreshTokenRaw, request);
|
||||
|
||||
if (result.type === "error") {
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
if (result.type === "error") {
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
|
||||
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
|
||||
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/logout
|
||||
fastify.post("/logout", async (request, reply) => {
|
||||
|
||||
@@ -74,6 +74,13 @@ export default async function companySettingsRoutes(
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (
|
||||
buf[0] === 0x52 &&
|
||||
buf[1] === 0x49 &&
|
||||
buf[8] === 0x57 &&
|
||||
buf[9] === 0x45
|
||||
)
|
||||
mime = "image/webp";
|
||||
|
||||
return reply
|
||||
.type(mime)
|
||||
@@ -324,15 +331,12 @@ export default async function companySettingsRoutes(
|
||||
nas: {
|
||||
projects: {
|
||||
configured: projectNas.isConfigured(),
|
||||
path: config.nas.path || "—",
|
||||
},
|
||||
financials: {
|
||||
configured: nasFinancialsManager.isConfigured(),
|
||||
path: config.nas.financialsPath || "—",
|
||||
},
|
||||
offers: {
|
||||
configured: nasOffersManager.isConfigured(),
|
||||
path: config.nas.offersPath || "—",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,54 +56,58 @@ function decodeCustomFields(raw: string | null): {
|
||||
export default async function customersRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(
|
||||
request.query as Record<string, unknown>,
|
||||
);
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
|
||||
|
||||
const where = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ company_id: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.customers.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
include: { _count: { select: { quotations: true } } },
|
||||
}),
|
||||
prisma.customers.count({ where }),
|
||||
]);
|
||||
|
||||
const enriched = customers.map((c) => {
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(
|
||||
c.custom_fields,
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("customers.view") },
|
||||
async (request, reply) => {
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(
|
||||
request.query as Record<string, unknown>,
|
||||
);
|
||||
return {
|
||||
...c,
|
||||
custom_fields,
|
||||
customer_field_order,
|
||||
quotation_count: c._count?.quotations ?? 0,
|
||||
};
|
||||
});
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: enriched,
|
||||
pagination: buildPaginationMeta(total, page, limit),
|
||||
});
|
||||
});
|
||||
const where = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ company_id: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.customers.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
include: { _count: { select: { quotations: true } } },
|
||||
}),
|
||||
prisma.customers.count({ where }),
|
||||
]);
|
||||
|
||||
const enriched = customers.map((c) => {
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(
|
||||
c.custom_fields,
|
||||
);
|
||||
return {
|
||||
...c,
|
||||
custom_fields,
|
||||
customer_field_order,
|
||||
quotation_count: c._count?.quotations ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: enriched,
|
||||
pagination: buildPaginationMeta(total, page, limit),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
{ preHandler: requirePermission("customers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
@@ -7,6 +7,12 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { getRate } from "../../services/exchange-rates";
|
||||
import { localDateStr } from "../../utils/date";
|
||||
import { parseId } from "../../utils/response";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -50,6 +56,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
@@ -78,7 +85,12 @@ function buildAddressLines(
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData =
|
||||
@@ -250,185 +262,189 @@ export default async function invoicesPdfRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.export") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
const lang = query.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
try {
|
||||
const lang = query.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
if (!invoice) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const items = await prisma.invoice_items.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
customer = (await prisma.customers.findUnique({
|
||||
where: { id: invoice.customer_id },
|
||||
})) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
let orderNumber = "";
|
||||
let orderDate = "";
|
||||
if (invoice.order_id) {
|
||||
const orderRow = await prisma.orders.findUnique({
|
||||
where: { id: invoice.order_id },
|
||||
select: {
|
||||
order_number: true,
|
||||
customer_order_number: true,
|
||||
created_at: true,
|
||||
},
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (orderRow) {
|
||||
orderNumber = escapeHtml(
|
||||
String(
|
||||
orderRow.customer_order_number || orderRow.order_number || "",
|
||||
),
|
||||
);
|
||||
if (orderRow.created_at) {
|
||||
orderDate = formatDate(orderRow.created_at);
|
||||
|
||||
if (!invoice) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const items = await prisma.invoice_items.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
customer = (await prisma.customers.findUnique({
|
||||
where: { id: invoice.customer_id },
|
||||
})) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
let orderNumber = "";
|
||||
let orderDate = "";
|
||||
if (invoice.order_id) {
|
||||
const orderRow = await prisma.orders.findUnique({
|
||||
where: { id: invoice.order_id },
|
||||
select: {
|
||||
order_number: true,
|
||||
customer_order_number: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
if (orderRow) {
|
||||
orderNumber = escapeHtml(
|
||||
String(
|
||||
orderRow.customer_order_number || orderRow.order_number || "",
|
||||
),
|
||||
);
|
||||
if (orderRow.created_at) {
|
||||
orderDate = formatDate(orderRow.created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
||||
subtotal += lineSubtotal;
|
||||
const rate = Number(item.vat_rate);
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
if (applyVat) {
|
||||
vatSummary[key].vat += (lineSubtotal * rate) / 100;
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
}
|
||||
|
||||
let totalVat = 0;
|
||||
for (const data of Object.values(vatSummary)) {
|
||||
totalVat += data.vat;
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
// QR code - SPAYD payment format
|
||||
let qrSvg = "";
|
||||
try {
|
||||
const spaydParts = [
|
||||
"SPD*1.0",
|
||||
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
|
||||
"AM:" + totalToPay.toFixed(2),
|
||||
"CC:" + currency,
|
||||
"X-VS:" + (invoice.invoice_number || ""),
|
||||
"X-KS:" + (invoice.constant_symbol || "0308"),
|
||||
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
|
||||
];
|
||||
const spaydString = spaydParts.join("*");
|
||||
qrSvg = await QRCode.toString(spaydString, {
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 200,
|
||||
});
|
||||
} catch {
|
||||
// QR generation failed — leave empty
|
||||
}
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
// VAT recapitulation (always in CZK — Czech tax requirement)
|
||||
const isForeign = currency.toUpperCase() !== "CZK";
|
||||
const issueDateStr = invoice.issue_date
|
||||
? localDateStr(new Date(invoice.issue_date))
|
||||
: undefined;
|
||||
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{
|
||||
rate: number;
|
||||
base: number;
|
||||
vat: number;
|
||||
total: number;
|
||||
}> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
for (const item of items) {
|
||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
||||
subtotal += lineSubtotal;
|
||||
const rate = Number(item.vat_rate);
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
if (applyVat) {
|
||||
vatSummary[key].vat +=
|
||||
Math.round(((lineSubtotal * rate) / 100) * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
let totalVat = 0;
|
||||
for (const data of Object.values(vatSummary)) {
|
||||
totalVat += data.vat;
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
// QR code - SPAYD payment format
|
||||
let qrSvg = "";
|
||||
try {
|
||||
const spaydParts = [
|
||||
"SPD*1.0",
|
||||
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
|
||||
"AM:" + totalToPay.toFixed(2),
|
||||
"CC:" + currency,
|
||||
"X-VS:" + (invoice.invoice_number || ""),
|
||||
"X-KS:" + (invoice.constant_symbol || "0308"),
|
||||
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
|
||||
];
|
||||
const spaydString = spaydParts.join("*");
|
||||
qrSvg = await QRCode.toString(spaydString, {
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 200,
|
||||
});
|
||||
} catch {
|
||||
// QR generation failed — leave empty
|
||||
}
|
||||
|
||||
// Supplier email/web from custom_fields
|
||||
let suppEmail = "";
|
||||
if (settings?.custom_fields) {
|
||||
const raw = settings.custom_fields;
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const fields = (parsed as Record<string, unknown>).fields;
|
||||
if (Array.isArray(fields)) {
|
||||
for (const f of fields) {
|
||||
if (f.name && f.name.toLowerCase() === "email" && f.value) {
|
||||
suppEmail = String(f.value);
|
||||
// VAT recapitulation (always in CZK — Czech tax requirement)
|
||||
const isForeign = currency.toUpperCase() !== "CZK";
|
||||
const issueDateStr = invoice.issue_date
|
||||
? localDateStr(new Date(invoice.issue_date))
|
||||
: undefined;
|
||||
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{
|
||||
rate: number;
|
||||
base: number;
|
||||
vat: number;
|
||||
total: number;
|
||||
}> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
// Supplier email/web from custom_fields
|
||||
let suppEmail = "";
|
||||
if (settings?.custom_fields) {
|
||||
const raw = settings.custom_fields;
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const fields = (parsed as Record<string, unknown>).fields;
|
||||
if (Array.isArray(fields)) {
|
||||
for (const f of fields) {
|
||||
if (f.name && f.name.toLowerCase() === "email" && f.value) {
|
||||
suppEmail = String(f.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceNumber = escapeHtml(invoice.invoice_number);
|
||||
const invoiceNumber = escapeHtml(invoice.invoice_number);
|
||||
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
const unitPrice = Number(item.unit_price);
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const vatRate = Number(item.vat_rate);
|
||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
const unitPrice = Number(item.unit_price);
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const vatRate = Number(item.vat_rate);
|
||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
|
||||
return `<tr>
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||
@@ -438,55 +454,55 @@ export default async function invoicesPdfRoutes(
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const vatRecapHtml = vatRecap
|
||||
.map(
|
||||
(vr) => `<tr>
|
||||
const vatRecapHtml = vatRecap
|
||||
.map(
|
||||
(vr) => `<tr>
|
||||
<td class="right">${formatNum(vr.base)}</td>
|
||||
<td class="center">${Math.floor(vr.rate)}%</td>
|
||||
<td class="right">${formatNum(vr.vat)}</td>
|
||||
<td class="right">${formatNum(vr.total)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
)
|
||||
.join("");
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<!-- Poznamky -->
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -1000,33 +1016,36 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Save PDF to NAS
|
||||
if (nasFinancialsManager.isConfigured() && invoice.invoice_number) {
|
||||
const issueDate = invoice.issue_date
|
||||
? new Date(invoice.issue_date)
|
||||
: new Date();
|
||||
const saveMode = query.save === "1";
|
||||
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
|
||||
const pdfPromise = htmlToPdf(html)
|
||||
.then((pdfBuffer) => {
|
||||
nasFinancialsManager.saveIssuedInvoicePdf(
|
||||
invoice.invoice_number!,
|
||||
issueDate.getFullYear(),
|
||||
issueDate.getMonth() + 1,
|
||||
pdfBuffer,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
request.log.error(err, "Failed to save invoice PDF to NAS");
|
||||
});
|
||||
|
||||
if (saveMode) {
|
||||
await pdfPromise;
|
||||
// Save PDF to NAS
|
||||
if (
|
||||
saveMode &&
|
||||
nasFinancialsManager.isConfigured() &&
|
||||
invoice.invoice_number
|
||||
) {
|
||||
const issueDate = invoice.issue_date
|
||||
? new Date(invoice.issue_date)
|
||||
: new Date();
|
||||
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
nasFinancialsManager.saveIssuedInvoicePdf(
|
||||
invoice.invoice_number!,
|
||||
issueDate.getFullYear(),
|
||||
issueDate.getMonth() + 1,
|
||||
pdfBuffer,
|
||||
);
|
||||
return reply.send({ success: true, message: "PDF uloženo" });
|
||||
}
|
||||
}
|
||||
|
||||
return reply.type("text/html").send(html);
|
||||
return reply.type("text/html").send(html);
|
||||
} catch (err) {
|
||||
request.log.error(err, "PDF generation failed");
|
||||
return reply
|
||||
.status(500)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,10 +191,8 @@ export default async function invoicesRoutes(
|
||||
if (!existing) return error(reply, "Faktura nenalezena", 404);
|
||||
|
||||
// Delete PDF from NAS
|
||||
if (existing.invoice_number && existing.issue_date) {
|
||||
const d = new Date(existing.issue_date);
|
||||
const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${existing.invoice_number}.pdf`;
|
||||
nasFinancialsManager.deleteIssuedInvoice(relPath);
|
||||
if (existing.invoice_number) {
|
||||
await nasFinancialsManager.cleanIssuedInvoice(existing.invoice_number);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -241,6 +241,19 @@ export default async function leaveRequestsRoutes(
|
||||
|
||||
const totalHours = totalBusinessDays * 8;
|
||||
|
||||
for (const ac of attendanceCreates) {
|
||||
const duplicate = await prisma.attendance.findFirst({
|
||||
where: { user_id: ac.user_id, shift_date: ac.shift_date },
|
||||
});
|
||||
if (duplicate) {
|
||||
return error(
|
||||
reply,
|
||||
"Pro zvolené datumy již existují záznamy docházky",
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Create attendance records for each business day
|
||||
if (attendanceCreates.length > 0) {
|
||||
@@ -331,6 +344,7 @@ export default async function leaveRequestsRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.leave_requests.findUnique({
|
||||
@@ -342,6 +356,10 @@ export default async function leaveRequestsRoutes(
|
||||
return error(reply, "Lze zrušit pouze čekající žádosti", 400);
|
||||
}
|
||||
|
||||
if (existing.user_id !== authData.userId) {
|
||||
return error(reply, "Nemáte oprávnění zrušit tuto žádost", 403);
|
||||
}
|
||||
|
||||
await prisma.leave_requests.update({
|
||||
where: { id },
|
||||
data: { status: "cancelled" },
|
||||
|
||||
@@ -4,6 +4,12 @@ import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId } from "../../utils/response";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
@@ -73,6 +79,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
// Replace with regular space (outside of tags)
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
@@ -102,7 +109,12 @@ function buildAddressLines(
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData =
|
||||
@@ -201,7 +213,8 @@ export default async function offersPdfRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
|
||||
try {
|
||||
@@ -349,7 +362,7 @@ export default async function offersPdfRoutes(
|
||||
if (title)
|
||||
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
scopeHtml += "</div>";
|
||||
@@ -761,28 +774,24 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const saveMode = query.save === "1";
|
||||
|
||||
// Save PDF to NAS
|
||||
if (nasOffersManager.isConfigured() && quotation.quotation_number) {
|
||||
if (
|
||||
saveMode &&
|
||||
nasOffersManager.isConfigured() &&
|
||||
quotation.quotation_number
|
||||
) {
|
||||
const created = quotation.created_at
|
||||
? new Date(quotation.created_at)
|
||||
: new Date();
|
||||
const saveMode = query.save === "1";
|
||||
const pdfPromise = htmlToPdf(html)
|
||||
.then((pdfBuffer) => {
|
||||
nasOffersManager.saveOfferPdf(
|
||||
quotation.quotation_number!,
|
||||
created.getFullYear(),
|
||||
pdfBuffer,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
request.log.error(err, "Failed to save offer PDF to NAS");
|
||||
});
|
||||
|
||||
if (saveMode) {
|
||||
await pdfPromise;
|
||||
return reply.send({ success: true, message: "PDF uloženo" });
|
||||
}
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
nasOffersManager.saveOfferPdf(
|
||||
quotation.quotation_number!,
|
||||
created.getFullYear(),
|
||||
pdfBuffer,
|
||||
);
|
||||
return reply.send({ success: true, message: "PDF uloženo" });
|
||||
}
|
||||
|
||||
return reply.type("text/html").send(html);
|
||||
|
||||
@@ -3,6 +3,31 @@ import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId, error } from "../../utils/response";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { z } from "zod";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
const OrderPdfBodySchema = z
|
||||
.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string(),
|
||||
quantity: z.number().min(0).finite(),
|
||||
unit: z.string(),
|
||||
unit_price: z.number().min(0).finite(),
|
||||
is_included_in_total: z.boolean().optional(),
|
||||
vat_rate: z.number().finite(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -46,6 +71,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
@@ -74,7 +100,12 @@ function buildAddressLines(
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData =
|
||||
@@ -213,129 +244,133 @@ export default async function ordersPdfRoutes(
|
||||
"/:id/confirmation",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const body = request.body || {};
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
const currency = order.currency || "CZK";
|
||||
const applyVat =
|
||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
||||
const orderVatRate = Number(order.vat_rate) || 21;
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
// Use custom items from body if provided, otherwise order items
|
||||
const customItemsRaw = body.items;
|
||||
let items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}> = [];
|
||||
const currency = order.currency || "CZK";
|
||||
const applyVat =
|
||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
||||
const orderVatRate = Number(order.vat_rate) || 21;
|
||||
|
||||
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it: Record<string, unknown>) => ({
|
||||
description: String(it.description || ""),
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: String(it.unit || ""),
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total:
|
||||
it.is_included_in_total !== false && it.is_included_in_total !== 0,
|
||||
vat_rate: Number(it.vat_rate) || orderVatRate,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
vat_rate: orderVatRate,
|
||||
}));
|
||||
}
|
||||
// Use custom items from body if provided, otherwise order items
|
||||
const customItemsRaw = body.items;
|
||||
let items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}> = [];
|
||||
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
subtotal += lineTotal;
|
||||
const rate = item.vat_rate;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineTotal;
|
||||
if (applyVat) {
|
||||
const lineVat = (lineTotal * rate) / 100;
|
||||
vatSummary[key].vat += lineVat;
|
||||
totalVat += lineVat;
|
||||
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
vat_rate: it.vat_rate,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
vat_rate: orderVatRate,
|
||||
}));
|
||||
}
|
||||
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
subtotal += lineTotal;
|
||||
const rate = item.vat_rate;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineTotal;
|
||||
if (applyVat) {
|
||||
const lineVat = (lineTotal * rate) / 100;
|
||||
vatSummary[key].vat += lineVat;
|
||||
totalVat += lineVat;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineSubtotal = item.quantity * item.unit_price;
|
||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals =
|
||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
return `<tr>
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineSubtotal = item.quantity * item.unit_price;
|
||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals =
|
||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||
@@ -345,45 +380,45 @@ export default async function ordersPdfRoutes(
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
|
||||
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -846,13 +881,20 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
.send(pdfBuffer);
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
.send(pdfBuffer);
|
||||
} catch (err) {
|
||||
request.log.error(err, "PDF generation failed");
|
||||
return reply
|
||||
.status(500)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,12 +88,10 @@ export default async function ordersRoutes(
|
||||
const attachment = await getOrderAttachment(id);
|
||||
if (!attachment) return error(reply, "Příloha nenalezena", 404);
|
||||
|
||||
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${attachment.filename}"`,
|
||||
)
|
||||
.header("Content-Disposition", `inline; filename="${safeFilename}"`)
|
||||
.send(attachment.data);
|
||||
},
|
||||
);
|
||||
@@ -209,6 +207,7 @@ export default async function ordersRoutes(
|
||||
const body = manualParsed.data;
|
||||
|
||||
const result = await createOrder(body);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
@@ -75,6 +75,14 @@ export default async function profileRoutes(
|
||||
}
|
||||
|
||||
await prisma.users.update({ where: { id: userId }, data });
|
||||
|
||||
if (body.current_password && body.new_password) {
|
||||
await prisma.refresh_tokens.updateMany({
|
||||
where: { user_id: userId, replaced_at: null },
|
||||
data: { replaced_at: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
return success(reply, null, 200, "Profil aktualizován");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "fs";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { z } from "zod";
|
||||
import prisma from "../../config/database";
|
||||
import { config } from "../../config/env";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
@@ -8,6 +9,28 @@ import { logAudit } from "../../services/audit";
|
||||
import { success, error } from "../../utils/response";
|
||||
import { NasFileManager } from "../../services/nas-file-manager";
|
||||
|
||||
const ProjectFilesQuerySchema = z.object({
|
||||
project_id: z.string().min(1, "project_id je povinný"),
|
||||
path: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
});
|
||||
|
||||
function parseProjectFilesQuery(
|
||||
query: unknown,
|
||||
):
|
||||
| { data: { project_id: string; path?: string; action?: string } }
|
||||
| { error: string } {
|
||||
try {
|
||||
const data = ProjectFilesQuerySchema.parse(query);
|
||||
return { data };
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
return { error: e.issues.map((err) => err.message).join(", ") };
|
||||
}
|
||||
return { error: "Neplatné parametry dotazu" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function projectFilesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
@@ -30,8 +53,10 @@ export default async function projectFilesRoutes(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const { project_id: projectIdStr, path: subPath = "" } = parsedQuery.data;
|
||||
const projectId = Number(projectIdStr);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
|
||||
@@ -39,9 +64,7 @@ export default async function projectFilesRoutes(
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const subPath = query.path || "";
|
||||
|
||||
if (query.action === "download") {
|
||||
if (parsedQuery.data.action === "download") {
|
||||
if (!subPath) return error(reply, "Cesta k souboru je povinná");
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
@@ -81,8 +104,9 @@ export default async function projectFilesRoutes(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const projectId = Number(parsedQuery.data.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
@@ -105,7 +129,11 @@ export default async function projectFilesRoutes(
|
||||
fm.createProjectFolder(project.project_number, project.name || "");
|
||||
}
|
||||
|
||||
const err = fm.createFolder(project.project_number, path, folderName);
|
||||
const err = await fm.createFolder(
|
||||
project.project_number,
|
||||
path,
|
||||
folderName,
|
||||
);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
@@ -130,8 +158,9 @@ export default async function projectFilesRoutes(
|
||||
bodyLimit: config.nas.maxUploadSize,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const projectId = Number(parsedQuery.data.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
@@ -149,14 +178,13 @@ export default async function projectFilesRoutes(
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, "Nebyl nahrán žádný soubor");
|
||||
|
||||
const subPath = query.path || "";
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const subPath = parsedQuery.data.path || "";
|
||||
const fileName = file.filename;
|
||||
|
||||
const err = await fm.uploadFile(
|
||||
project.project_number,
|
||||
subPath,
|
||||
fileBuffer,
|
||||
file.file,
|
||||
fileName,
|
||||
);
|
||||
if (err !== null) return error(reply, err);
|
||||
@@ -180,8 +208,9 @@ export default async function projectFilesRoutes(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const projectId = Number(parsedQuery.data.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
@@ -198,7 +227,7 @@ export default async function projectFilesRoutes(
|
||||
if (!fromPath || !toPath)
|
||||
return error(reply, "Zdrojová i cílová cesta jsou povinné");
|
||||
|
||||
const err = fm.moveItem(project.project_number, fromPath, toPath);
|
||||
const err = await fm.moveItem(project.project_number, fromPath, toPath);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
@@ -221,8 +250,9 @@ export default async function projectFilesRoutes(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const projectId = Number(parsedQuery.data.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
@@ -232,7 +262,7 @@ export default async function projectFilesRoutes(
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const filePath = query.path || "";
|
||||
const filePath = parsedQuery.data.path || "";
|
||||
if (!filePath) return error(reply, "Cesta k souboru je povinná");
|
||||
|
||||
const err = await fm.deleteItem(project.project_number, filePath);
|
||||
|
||||
@@ -69,6 +69,9 @@ export default async function projectsRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const project = await createProject(parsed.data);
|
||||
if ("error" in project) {
|
||||
return error(reply, project.error, (project as any).status ?? 400);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
@@ -245,6 +245,8 @@ export default async function quotationsRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const quotation = await createOffer(parsed.data);
|
||||
if ("error" in quotation)
|
||||
return error(reply, quotation.error!, quotation.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -312,9 +314,13 @@ export default async function quotationsRoutes(
|
||||
// Delete PDF from NAS
|
||||
if (existing.quotation_number && existing.created_at) {
|
||||
const yr = new Date(existing.created_at).getFullYear();
|
||||
nasOffersManager.deleteOfferPdf(
|
||||
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
|
||||
);
|
||||
try {
|
||||
await nasOffersManager.deleteOfferPdf(
|
||||
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
|
||||
);
|
||||
} catch {
|
||||
// Non-fatal: NAS delete may fail if file does not exist
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -111,13 +111,15 @@ export default async function rolesRoutes(
|
||||
});
|
||||
|
||||
if (Array.isArray(body.permission_ids)) {
|
||||
await prisma.role_permissions.deleteMany({ where: { role_id: id } });
|
||||
await prisma.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
});
|
||||
await prisma.$transaction([
|
||||
prisma.role_permissions.deleteMany({ where: { role_id: id } }),
|
||||
prisma.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -54,6 +54,39 @@ export default async function totpRoutes(
|
||||
return error(reply, "Secret a kód jsou povinné", 400);
|
||||
}
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: request.authData!.userId },
|
||||
});
|
||||
if (!user) return error(reply, "Uživatel nenalezen", 404);
|
||||
|
||||
if (user.totp_enabled) {
|
||||
if (!body.current_code) {
|
||||
return error(
|
||||
reply,
|
||||
"Aktuální TOTP kód je povinný pro změnu 2FA",
|
||||
400,
|
||||
);
|
||||
}
|
||||
const isValid = OTPAuth.verify(
|
||||
user.totp_secret!,
|
||||
String(body.current_code),
|
||||
);
|
||||
if (!isValid) {
|
||||
return error(reply, "Neplatný aktuální TOTP kód", 400);
|
||||
}
|
||||
} else {
|
||||
if (!body.password) {
|
||||
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
|
||||
}
|
||||
const valid = await bcrypt.compare(
|
||||
String(body.password),
|
||||
user.password_hash,
|
||||
);
|
||||
if (!valid) {
|
||||
return error(reply, "Nesprávné heslo", 400);
|
||||
}
|
||||
}
|
||||
|
||||
const totp = new OTPAuthLib.TOTP({
|
||||
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
|
||||
algorithm: "SHA1",
|
||||
@@ -185,10 +218,26 @@ export default async function totpRoutes(
|
||||
|
||||
const required =
|
||||
body.required === true || body.required === 1 || body.required === "1";
|
||||
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { require_2fa: true },
|
||||
});
|
||||
const oldValue = settings?.require_2fa ?? false;
|
||||
|
||||
await prisma.company_settings.updateMany({
|
||||
data: { require_2fa: required },
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "company_settings",
|
||||
description: `Povinné 2FA změněno z ${oldValue ? "zapnuto" : "vypnuto"} na ${required ? "zapnuto" : "vypnuto"}`,
|
||||
oldValues: { require_2fa: oldValue },
|
||||
newValues: { require_2fa: required },
|
||||
});
|
||||
|
||||
const message = required
|
||||
? "2FA je nyní povinné pro všechny uživatele"
|
||||
: "2FA již není povinné";
|
||||
@@ -200,7 +249,15 @@ export default async function totpRoutes(
|
||||
// POST - verify backup code (pre-auth, no requireAuth)
|
||||
fastify.post(
|
||||
"/backup-verify",
|
||||
{ bodyLimit: 10240 },
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 5,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpBackupSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
@@ -211,52 +268,95 @@ export default async function totpRoutes(
|
||||
.update(login_token)
|
||||
.digest("hex");
|
||||
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
const settings = await getSystemSettings();
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, "Neplatný nebo expirovaný login token", 401);
|
||||
}
|
||||
const txResult = await prisma.$transaction(async (tx) => {
|
||||
const tokens = await tx.$queryRaw<
|
||||
Array<{ id: number; user_id: number; expires_at: Date }>
|
||||
>`
|
||||
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
|
||||
`;
|
||||
const storedToken = tokens[0] ?? null;
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_backup_codes) {
|
||||
return error(reply, "Uživatel nenalezen", 401);
|
||||
}
|
||||
|
||||
const backupCodes: string[] = JSON.parse(
|
||||
user.totp_backup_codes as string,
|
||||
);
|
||||
let matchIndex = -1;
|
||||
|
||||
for (let i = 0; i < backupCodes.length; i++) {
|
||||
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
|
||||
if (isMatch) {
|
||||
matchIndex = i;
|
||||
break;
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return { error: "Neplatný nebo expirovaný login token", status: 401 };
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return error(reply, "Neplatný záložní kód", 401);
|
||||
}
|
||||
const user = await tx.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
backupCodes.splice(matchIndex, 1);
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totp_backup_codes: JSON.stringify(backupCodes),
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
last_login: new Date(),
|
||||
},
|
||||
if (!user || !user.totp_backup_codes) {
|
||||
return { error: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return { error: "Účet je deaktivován", status: 401 };
|
||||
}
|
||||
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
return { error: "Účet je dočasně uzamčen", status: 429 };
|
||||
}
|
||||
|
||||
const backupCodes: string[] = JSON.parse(
|
||||
user.totp_backup_codes as string,
|
||||
);
|
||||
let matchIndex = -1;
|
||||
|
||||
for (let i = 0; i < backupCodes.length; i++) {
|
||||
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
|
||||
if (isMatch) {
|
||||
matchIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex === -1) {
|
||||
const newFailedAttempts = (user.failed_login_attempts ?? 0) + 1;
|
||||
if (newFailedAttempts >= settings.max_login_attempts) {
|
||||
await tx.totp_login_tokens.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failed_login_attempts: newFailedAttempts,
|
||||
locked_until: new Date(
|
||||
Date.now() + settings.lockout_minutes * 60_000,
|
||||
),
|
||||
},
|
||||
});
|
||||
return { error: "Účet je dočasně uzamčen", status: 429 };
|
||||
}
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: { failed_login_attempts: newFailedAttempts },
|
||||
});
|
||||
return { error: "Neplatný záložní kód", status: 401 };
|
||||
}
|
||||
|
||||
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
backupCodes.splice(matchIndex, 1);
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totp_backup_codes: JSON.stringify(backupCodes),
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
last_login: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { user };
|
||||
});
|
||||
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
if ("error" in txResult) {
|
||||
return error(reply, txResult.error!, txResult.status!);
|
||||
}
|
||||
|
||||
const user = txResult.user;
|
||||
|
||||
// Create tokens (same as /login/totp flow)
|
||||
const { loadAuthData } = await import("../../services/auth");
|
||||
|
||||
@@ -206,6 +206,10 @@ export default async function tripsRoutes(
|
||||
const body = parsed.data;
|
||||
const authData = request.authData!;
|
||||
|
||||
if (body.end_km < body.start_km) {
|
||||
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
|
||||
}
|
||||
|
||||
const trip = await prisma.trips.create({
|
||||
data: {
|
||||
vehicle_id: Number(body.vehicle_id),
|
||||
@@ -247,6 +251,18 @@ export default async function tripsRoutes(
|
||||
const body = parsed.data;
|
||||
const authData = request.authData!;
|
||||
|
||||
if (
|
||||
body.end_km != null &&
|
||||
body.start_km != null &&
|
||||
body.end_km < body.start_km
|
||||
) {
|
||||
return error(
|
||||
reply,
|
||||
"Konečný stav km nesmí být menší než počáteční",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.trips.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Jízda nenalezena", 404);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
@@ -59,15 +60,18 @@ export default async function usersRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const result = await createUser({
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
role_id: body.role_id,
|
||||
is_active: body.is_active,
|
||||
});
|
||||
const result = await createUser(
|
||||
{
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
role_id: body.role_id,
|
||||
is_active: body.is_active,
|
||||
},
|
||||
request.authData?.roleName ?? undefined,
|
||||
);
|
||||
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
@@ -106,9 +110,20 @@ export default async function usersRoutes(
|
||||
? Number(parsed.data.role_id)
|
||||
: (parsed.data.role_id as number | null | undefined),
|
||||
};
|
||||
const result = await updateUser(id, userData);
|
||||
const result = await updateUser(
|
||||
id,
|
||||
userData,
|
||||
request.authData?.roleName ?? undefined,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
if (parsed.data.password) {
|
||||
await prisma.refresh_tokens.updateMany({
|
||||
where: { user_id: id, replaced_at: null },
|
||||
data: { replaced_at: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
|
||||
@@ -6,12 +6,14 @@ const QuotationItemSchema = z.object({
|
||||
quantity: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v) || 1)
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(1),
|
||||
unit: z.string().nullish(),
|
||||
unit_price: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v) || 0)
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(0),
|
||||
is_included_in_total: z
|
||||
@@ -21,6 +23,7 @@ const QuotationItemSchema = z.object({
|
||||
position: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -31,6 +34,7 @@ const ScopeSectionSchema = z.object({
|
||||
position: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -40,6 +44,7 @@ export const CreateQuotationSchema = z.object({
|
||||
customer_id: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
|
||||
.nullish(),
|
||||
valid_until: z.string().nullish(),
|
||||
currency: z.string().optional().default("CZK"),
|
||||
@@ -47,6 +52,7 @@ export const CreateQuotationSchema = z.object({
|
||||
vat_rate: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(21.0),
|
||||
apply_vat: z
|
||||
@@ -56,9 +62,13 @@ export const CreateQuotationSchema = z.object({
|
||||
exchange_rate: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(1.0),
|
||||
status: z.string().optional().default("active"),
|
||||
status: z
|
||||
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"])
|
||||
.optional()
|
||||
.default("nova"),
|
||||
scope_title: z.string().nullish(),
|
||||
scope_description: z.string().nullish(),
|
||||
items: z.array(QuotationItemSchema).optional(),
|
||||
@@ -70,6 +80,7 @@ export const UpdateQuotationSchema = z.object({
|
||||
customer_id: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional(),
|
||||
valid_until: z.union([z.string(), z.null()]).optional(),
|
||||
currency: z.string().optional(),
|
||||
@@ -77,6 +88,7 @@ export const UpdateQuotationSchema = z.object({
|
||||
vat_rate: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional(),
|
||||
apply_vat: z
|
||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||
@@ -84,8 +96,11 @@ export const UpdateQuotationSchema = z.object({
|
||||
exchange_rate: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional(),
|
||||
status: z
|
||||
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"])
|
||||
.optional(),
|
||||
status: z.string().optional(),
|
||||
scope_title: z.string().nullish(),
|
||||
scope_description: z.string().nullish(),
|
||||
items: z.array(QuotationItemSchema).optional(),
|
||||
|
||||
@@ -6,12 +6,14 @@ const OrderItemSchema = z.object({
|
||||
quantity: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v) || 1)
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(1),
|
||||
unit: z.string().nullish(),
|
||||
unit_price: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v) || 0)
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(0),
|
||||
is_included_in_total: z
|
||||
@@ -21,6 +23,7 @@ const OrderItemSchema = z.object({
|
||||
position: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -31,11 +34,15 @@ const OrderSectionSchema = z.object({
|
||||
position: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const CreateOrderFromQuotationSchema = z.object({
|
||||
quotationId: z.union([z.number(), z.string()]).transform((v) => Number(v)),
|
||||
quotationId: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" }),
|
||||
customerOrderNumber: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
@@ -45,17 +52,23 @@ export const CreateOrderSchema = z.object({
|
||||
quotation_id: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
|
||||
.nullish(),
|
||||
customer_id: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
|
||||
.nullish(),
|
||||
status: z.string().optional().default("prijata"),
|
||||
status: z
|
||||
.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"])
|
||||
.optional()
|
||||
.default("prijata"),
|
||||
currency: z.string().optional().default("CZK"),
|
||||
language: z.string().optional().default("cs"),
|
||||
vat_rate: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(21.0),
|
||||
apply_vat: z
|
||||
@@ -65,6 +78,7 @@ export const CreateOrderSchema = z.object({
|
||||
exchange_rate: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(1.0),
|
||||
scope_title: z.string().nullish(),
|
||||
@@ -76,7 +90,7 @@ export const CreateOrderSchema = z.object({
|
||||
|
||||
export const UpdateOrderSchema = z.object({
|
||||
customer_order_number: z.string().nullish(),
|
||||
status: z.string().optional(),
|
||||
status: z.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]).optional(),
|
||||
currency: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
scope_title: z.string().nullish(),
|
||||
@@ -86,6 +100,7 @@ export const UpdateOrderSchema = z.object({
|
||||
vat_rate: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional(),
|
||||
apply_vat: z
|
||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||
|
||||
@@ -254,10 +254,7 @@ export async function getStatus(userId: number) {
|
||||
}
|
||||
|
||||
const worked = Math.round(workedHours * 100) / 100;
|
||||
const holidayDays = monthRecords.filter(
|
||||
(r) => (r.leave_type as string) === "holiday",
|
||||
).length;
|
||||
const adjustedFund = Math.max(0, (workingDays - holidayDays) * 8);
|
||||
const adjustedFund = Math.max(0, fund);
|
||||
const leaveHours = vacationHours + sickHours;
|
||||
const covered = worked + leaveHours;
|
||||
const remaining = Math.max(0, adjustedFund - covered);
|
||||
@@ -266,7 +263,7 @@ export async function getStatus(userId: number) {
|
||||
const monthlyFund = {
|
||||
month_name: `${MONTH_NAMES[m]} ${y}`,
|
||||
fund: adjustedFund,
|
||||
business_days: workingDays - holidayDays,
|
||||
business_days: workingDays,
|
||||
worked,
|
||||
covered,
|
||||
remaining,
|
||||
@@ -390,7 +387,11 @@ export async function updateAddress(
|
||||
punchAction: string,
|
||||
) {
|
||||
const latest = await prisma.attendance.findFirst({
|
||||
where: { user_id: userId },
|
||||
where: {
|
||||
user_id: userId,
|
||||
departure_time: null,
|
||||
arrival_time: { not: null },
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
if (!latest) return { error: "Nenalezen záznam" };
|
||||
@@ -574,7 +575,6 @@ export async function getWorkfund(year: number) {
|
||||
let worked = 0;
|
||||
let vacationHours = 0;
|
||||
let sickHours = 0;
|
||||
let holidayDays = 0;
|
||||
|
||||
for (const rec of recs) {
|
||||
const lt = (rec.leave_type as string) || "work";
|
||||
@@ -591,8 +591,6 @@ export async function getWorkfund(year: number) {
|
||||
vacationHours += Number(rec.leave_hours) || 8;
|
||||
} else if (lt === "sick") {
|
||||
sickHours += Number(rec.leave_hours) || 8;
|
||||
} else if (lt === "holiday") {
|
||||
holidayDays++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1224,6 +1222,12 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
||||
0,
|
||||
),
|
||||
);
|
||||
const duplicate = await prisma.attendance.findFirst({
|
||||
where: { user_id: userId, shift_date: shiftDate },
|
||||
});
|
||||
if (duplicate) {
|
||||
return { error: "Pro zvolené datumy již existují záznamy docházky" };
|
||||
}
|
||||
await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
@@ -1438,10 +1442,36 @@ export async function createAttendance(
|
||||
data: CreateAttendanceData,
|
||||
authUserId: number,
|
||||
) {
|
||||
const userId = data.user_id ?? authUserId;
|
||||
const shiftDate = new Date(data.shift_date);
|
||||
const startOfDay = new Date(
|
||||
shiftDate.getFullYear(),
|
||||
shiftDate.getMonth(),
|
||||
shiftDate.getDate(),
|
||||
);
|
||||
const endOfDay = new Date(
|
||||
shiftDate.getFullYear(),
|
||||
shiftDate.getMonth(),
|
||||
shiftDate.getDate() + 1,
|
||||
);
|
||||
|
||||
const duplicate = await prisma.attendance.findFirst({
|
||||
where: {
|
||||
user_id: userId,
|
||||
shift_date: { gte: startOfDay, lt: endOfDay },
|
||||
},
|
||||
});
|
||||
if (duplicate) {
|
||||
return {
|
||||
error: "Pro zvolené datumy již existují záznamy docházky",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const record = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: data.user_id ?? authUserId,
|
||||
shift_date: new Date(data.shift_date),
|
||||
user_id: userId,
|
||||
shift_date: shiftDate,
|
||||
arrival_time: data.arrival_time ? new Date(data.arrival_time) : null,
|
||||
arrival_lat: data.arrival_lat ?? null,
|
||||
arrival_lng: data.arrival_lng ?? null,
|
||||
|
||||
@@ -109,32 +109,42 @@ export async function login(
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return { type: "error", message: "Účet je deaktivován", status: 403 };
|
||||
request.log.warn(`Login failed for deactivated user: ${username}`);
|
||||
return {
|
||||
type: "error",
|
||||
message: "Neplatné přihlašovací údaje",
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
request.log.warn(`Login failed for locked user: ${username}`);
|
||||
return {
|
||||
type: "error",
|
||||
message: "Účet je dočasně uzamčen. Zkuste to později.",
|
||||
status: 429,
|
||||
message: "Neplatné přihlašovací údaje",
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!passwordValid) {
|
||||
const settings = await getSystemSettings();
|
||||
const attempts = (user.failed_login_attempts ?? 0) + 1;
|
||||
const updateData: Record<string, unknown> = {
|
||||
failed_login_attempts: attempts,
|
||||
};
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: { failed_login_attempts: { increment: 1 } },
|
||||
});
|
||||
|
||||
if (attempts >= settings.max_login_attempts) {
|
||||
updateData.locked_until = new Date(
|
||||
Date.now() + settings.lockout_minutes * 60_000,
|
||||
);
|
||||
if ((user.failed_login_attempts ?? 0) + 1 >= settings.max_login_attempts) {
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
locked_until: new Date(
|
||||
Date.now() + settings.lockout_minutes * 60_000,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.users.update({ where: { id: user.id }, data: updateData });
|
||||
return {
|
||||
type: "error",
|
||||
message: "Neplatné přihlašovací údaje",
|
||||
@@ -151,6 +161,17 @@ export async function login(
|
||||
},
|
||||
});
|
||||
|
||||
const companySettings = await prisma.company_settings.findFirst({
|
||||
select: { require_2fa: true },
|
||||
});
|
||||
if (companySettings?.require_2fa && !user.totp_enabled) {
|
||||
return {
|
||||
type: "error",
|
||||
message: "Dvoufázové ověření je povinné",
|
||||
status: 403,
|
||||
};
|
||||
}
|
||||
|
||||
if (user.totp_enabled) {
|
||||
const loginToken = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = hashToken(loginToken);
|
||||
@@ -222,60 +243,69 @@ export async function refreshAccessToken(
|
||||
> {
|
||||
const tokenHash = hashToken(refreshTokenRaw);
|
||||
|
||||
const storedToken = await prisma.refresh_tokens.findUnique({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const tokens = await tx.$queryRaw<
|
||||
Array<{
|
||||
id: number;
|
||||
user_id: number;
|
||||
expires_at: Date;
|
||||
replaced_at: Date | null;
|
||||
remember_me: boolean | null;
|
||||
}>
|
||||
>`
|
||||
SELECT id, user_id, expires_at, replaced_at, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
|
||||
`;
|
||||
const storedToken = tokens[0] ?? null;
|
||||
|
||||
if (
|
||||
!storedToken ||
|
||||
storedToken.replaced_at ||
|
||||
new Date(storedToken.expires_at) < new Date()
|
||||
) {
|
||||
return { type: "error", message: "Neplatný refresh token", status: 401 };
|
||||
}
|
||||
if (
|
||||
!storedToken ||
|
||||
storedToken.replaced_at ||
|
||||
new Date(storedToken.expires_at) < new Date()
|
||||
) {
|
||||
return { type: "error", message: "Neplatný refresh token", status: 401 };
|
||||
}
|
||||
|
||||
const authData = await loadAuthData(storedToken.user_id);
|
||||
if (!authData) {
|
||||
return { type: "error", message: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
const authData = await loadAuthData(storedToken.user_id);
|
||||
if (!authData) {
|
||||
return { type: "error", message: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
|
||||
const newRefreshTokenRaw = generateRefreshToken();
|
||||
const newRefreshTokenHash = hashToken(newRefreshTokenRaw);
|
||||
const newRefreshTokenRaw = generateRefreshToken();
|
||||
const newRefreshTokenHash = hashToken(newRefreshTokenRaw);
|
||||
|
||||
const expiresIn = storedToken.remember_me
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
const expiresIn = storedToken.remember_me
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.refresh_tokens.update({
|
||||
await tx.refresh_tokens.update({
|
||||
where: { id: storedToken.id },
|
||||
data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash },
|
||||
}),
|
||||
prisma.refresh_tokens.create({
|
||||
});
|
||||
await tx.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: storedToken.user_id,
|
||||
token_hash: newRefreshTokenHash,
|
||||
expires_at: new Date(Date.now() + expiresIn * 1000),
|
||||
remember_me: storedToken.remember_me,
|
||||
remember_me: storedToken.remember_me ?? false,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers["user-agent"] ?? null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
const accessToken = generateAccessToken({
|
||||
id: authData.userId,
|
||||
username: authData.username,
|
||||
roleName: authData.roleName,
|
||||
const accessToken = generateAccessToken({
|
||||
id: authData.userId,
|
||||
username: authData.username,
|
||||
roleName: authData.roleName,
|
||||
});
|
||||
|
||||
return {
|
||||
type: "success",
|
||||
accessToken,
|
||||
refreshToken: newRefreshTokenRaw,
|
||||
user: authData,
|
||||
rememberMe: storedToken.remember_me ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
type: "success",
|
||||
accessToken,
|
||||
refreshToken: newRefreshTokenRaw,
|
||||
user: authData,
|
||||
rememberMe: storedToken.remember_me ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function logout(refreshTokenRaw: string): Promise<void> {
|
||||
|
||||
@@ -37,7 +37,7 @@ async function fetchRatesForDate(
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch CNB exchange rates:", err);
|
||||
if (rateCache["today"]) return rateCache["today"];
|
||||
return { CZK: 1, EUR: 25, USD: 22, GBP: 28 };
|
||||
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function toCzk(
|
||||
if (currency === "CZK") return amount;
|
||||
const rates = await fetchRatesForDate(date);
|
||||
const rate = rates[currency];
|
||||
if (!rate) return amount;
|
||||
if (!rate) throw new Error(`Neznámá měna: ${currency}`);
|
||||
return Math.round(amount * rate * 100) / 100;
|
||||
}
|
||||
|
||||
@@ -61,5 +61,7 @@ export async function getRate(
|
||||
): Promise<number> {
|
||||
if (currency === "CZK") return 1;
|
||||
const rates = await fetchRatesForDate(date);
|
||||
return rates[currency] || 1;
|
||||
const rate = rates[currency];
|
||||
if (!rate) throw new Error(`Neznámá měna: ${currency}`);
|
||||
return rate;
|
||||
}
|
||||
|
||||
@@ -48,11 +48,15 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
||||
const createdInvoices = await prisma.invoices.findMany({
|
||||
where: {
|
||||
status: { in: ["issued", "overdue"] },
|
||||
due_date: { not: null },
|
||||
due_date: { gte: today, lte: in3days },
|
||||
},
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
invoice_number: true,
|
||||
due_date: true,
|
||||
currency: true,
|
||||
customers: { select: { name: true } },
|
||||
invoice_items: true,
|
||||
invoice_items: { select: { quantity: true, unit_price: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,7 +109,15 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
||||
const receivedInvoices = await prisma.received_invoices.findMany({
|
||||
where: {
|
||||
status: "unpaid",
|
||||
due_date: { not: null },
|
||||
due_date: { gte: today, lte: in3days },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
invoice_number: true,
|
||||
supplier_name: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
due_date: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -55,10 +55,9 @@ function computeInvoiceTotals(
|
||||
const vatAmount = applyVat
|
||||
? items.reduce((s, i) => {
|
||||
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
||||
return (
|
||||
s +
|
||||
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100)
|
||||
);
|
||||
const vat =
|
||||
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100);
|
||||
return s + Math.round(vat * 100) / 100;
|
||||
}, 0)
|
||||
: 0;
|
||||
return {
|
||||
@@ -156,6 +155,31 @@ export {
|
||||
previewInvoiceNumber as getNextInvoiceNumberPreview,
|
||||
} from "./numbering.service";
|
||||
|
||||
function invoiceTotalWithVat(inv: {
|
||||
apply_vat: boolean | null;
|
||||
vat_rate: { toNumber(): number } | null;
|
||||
currency: string | null;
|
||||
invoice_items: Array<{
|
||||
quantity: { toNumber(): number } | null;
|
||||
unit_price: { toNumber(): number } | null;
|
||||
vat_rate: { toNumber(): number } | null;
|
||||
}>;
|
||||
}) {
|
||||
const sub = inv.invoice_items.reduce(
|
||||
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
const vat = inv.apply_vat
|
||||
? inv.invoice_items.reduce((s, i) => {
|
||||
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
||||
const lineVat =
|
||||
base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100);
|
||||
return s + Math.round(lineVat * 100) / 100;
|
||||
}, 0)
|
||||
: 0;
|
||||
return sub + vat;
|
||||
}
|
||||
|
||||
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
const now = new Date();
|
||||
const year = queryYear || now.getFullYear();
|
||||
@@ -163,29 +187,35 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
|
||||
const monthStart = new Date(year, month - 1, 1);
|
||||
const monthEnd = new Date(year, month, 0, 23, 59, 59);
|
||||
const startOfYear = new Date(year, 0, 1);
|
||||
const endOfYear = new Date(year, 11, 31, 23, 59, 59);
|
||||
|
||||
const allInvoices = await prisma.invoices.findMany({
|
||||
include: { invoice_items: true },
|
||||
});
|
||||
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
|
||||
prisma.invoices.findMany({
|
||||
where: {
|
||||
issue_date: { gte: monthStart, lte: monthEnd },
|
||||
},
|
||||
include: { invoice_items: true },
|
||||
}),
|
||||
prisma.invoices.findMany({
|
||||
where: {
|
||||
status: "issued",
|
||||
issue_date: { gte: startOfYear, lte: endOfYear },
|
||||
},
|
||||
include: { invoice_items: true },
|
||||
}),
|
||||
prisma.invoices.findMany({
|
||||
where: {
|
||||
status: "overdue",
|
||||
issue_date: { gte: startOfYear, lte: endOfYear },
|
||||
},
|
||||
include: { invoice_items: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const invoiceTotalWithVat = (inv: (typeof allInvoices)[0]) => {
|
||||
const sub = inv.invoice_items.reduce(
|
||||
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
const vat = inv.apply_vat
|
||||
? inv.invoice_items.reduce((s, i) => {
|
||||
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
||||
return (
|
||||
s +
|
||||
base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100)
|
||||
);
|
||||
}, 0)
|
||||
: 0;
|
||||
return sub + vat;
|
||||
};
|
||||
|
||||
const aggregateByCurrency = (invoices: typeof allInvoices) => {
|
||||
const aggregateByCurrency = (
|
||||
invoices: Parameters<typeof invoiceTotalWithVat>[0][],
|
||||
) => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const inv of invoices) {
|
||||
const cur = inv.currency || "CZK";
|
||||
@@ -199,7 +229,9 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
}));
|
||||
};
|
||||
|
||||
const sumCzk = async (invoices: typeof allInvoices) => {
|
||||
const sumCzk = async (
|
||||
invoices: Parameters<typeof invoiceTotalWithVat>[0][],
|
||||
) => {
|
||||
let total = 0;
|
||||
for (const inv of invoices) {
|
||||
const amount = invoiceTotalWithVat(inv);
|
||||
@@ -208,14 +240,7 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
return Math.round(total * 100) / 100;
|
||||
};
|
||||
|
||||
const monthInvoices = allInvoices.filter((inv) => {
|
||||
const issueDate = inv.issue_date ? new Date(inv.issue_date) : null;
|
||||
return issueDate && issueDate >= monthStart && issueDate <= monthEnd;
|
||||
});
|
||||
|
||||
const paidInvoices = monthInvoices.filter((i) => i.status === "paid");
|
||||
const awaitingInvoices = allInvoices.filter((i) => i.status === "issued");
|
||||
const overdueInvoices = allInvoices.filter((i) => i.status === "overdue");
|
||||
|
||||
const vatMap: Record<string, number> = {};
|
||||
for (const inv of monthInvoices) {
|
||||
@@ -454,8 +479,8 @@ export async function deleteInvoice(id: number) {
|
||||
|
||||
await prisma.invoices.delete({ where: { id } });
|
||||
|
||||
const year = existing.created_at
|
||||
? new Date(existing.created_at).getFullYear()
|
||||
const year = existing.invoice_number
|
||||
? Number(existing.invoice_number.split("/")[1]) || new Date().getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseInvoiceNumber(year);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { config } from "../config/env";
|
||||
import { localDateStr, localTimeStr } from "../utils/date";
|
||||
|
||||
@@ -294,21 +295,21 @@ export class NasFileManager {
|
||||
public async uploadFile(
|
||||
projectNumber: string,
|
||||
subPath: string,
|
||||
fileBuffer: Buffer,
|
||||
fileStream: NodeJS.ReadableStream,
|
||||
fileName: string,
|
||||
): Promise<string | null> {
|
||||
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
||||
if (
|
||||
dirPath === null ||
|
||||
!fs.existsSync(dirPath) ||
|
||||
!fs.statSync(dirPath).isDirectory()
|
||||
) {
|
||||
if (dirPath === null) {
|
||||
return "Cílová složka neexistuje";
|
||||
}
|
||||
|
||||
if (fileBuffer.length > config.nas.maxUploadSize) {
|
||||
const maxMb = Math.round(config.nas.maxUploadSize / 1048576);
|
||||
return `Soubor je příliš velký (max ${maxMb} MB)`;
|
||||
try {
|
||||
const stat = await fs.promises.stat(dirPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return "Cílová složka neexistuje";
|
||||
}
|
||||
} catch {
|
||||
return "Cílová složka neexistuje";
|
||||
}
|
||||
|
||||
const originalName = path.basename(fileName);
|
||||
@@ -322,9 +323,22 @@ export class NasFileManager {
|
||||
return "Tento typ souboru není povolen";
|
||||
}
|
||||
|
||||
const tempPath = path.join(
|
||||
require("os").tmpdir(),
|
||||
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const typeResult = await FileType.fromBuffer(fileBuffer);
|
||||
await pipeline(fileStream, fs.createWriteStream(tempPath));
|
||||
} catch {
|
||||
await fs.promises.unlink(tempPath).catch(() => {});
|
||||
return "Nepodařilo se uložit soubor";
|
||||
}
|
||||
|
||||
try {
|
||||
const typeResult = await FileType.fromFile(tempPath);
|
||||
if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) {
|
||||
await fs.promises.unlink(tempPath).catch(() => {});
|
||||
return "Obsah souboru neodpovídá jeho příponě";
|
||||
}
|
||||
} catch {
|
||||
@@ -333,19 +347,28 @@ export class NasFileManager {
|
||||
|
||||
let destPath = dirPath + "/" + safeName;
|
||||
|
||||
if (fs.existsSync(destPath)) {
|
||||
try {
|
||||
await fs.promises.stat(destPath);
|
||||
const base = path.basename(safeName, ext ? "." + ext : "");
|
||||
let counter = 1;
|
||||
do {
|
||||
safeName = base + "_" + counter + (ext ? "." + ext : "");
|
||||
destPath = dirPath + "/" + safeName;
|
||||
counter++;
|
||||
} while (fs.existsSync(destPath));
|
||||
} while (
|
||||
await fs.promises
|
||||
.stat(destPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
);
|
||||
} catch {
|
||||
// destPath does not exist, continue
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(destPath, fileBuffer);
|
||||
await fs.promises.rename(tempPath, destPath);
|
||||
} catch {
|
||||
await fs.promises.unlink(tempPath).catch(() => {});
|
||||
return "Nepodařilo se uložit soubor";
|
||||
}
|
||||
|
||||
@@ -381,7 +404,12 @@ export class NasFileManager {
|
||||
projectNumber: string,
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
if (filePath === "" || filePath === "/") {
|
||||
if (
|
||||
filePath === "" ||
|
||||
filePath === "/" ||
|
||||
filePath === "." ||
|
||||
filePath === "./"
|
||||
) {
|
||||
return "Nelze smazat kořenovou složku projektu";
|
||||
}
|
||||
|
||||
@@ -390,22 +418,19 @@ export class NasFileManager {
|
||||
return "Neplatná cesta";
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return "Soubor nebo složka neexistuje";
|
||||
}
|
||||
|
||||
let isDir: boolean;
|
||||
try {
|
||||
isDir = fs.lstatSync(fullPath).isDirectory();
|
||||
const stat = await fs.promises.lstat(fullPath);
|
||||
isDir = stat.isDirectory();
|
||||
} catch {
|
||||
return "Neplatná cesta";
|
||||
return "Soubor nebo složka neexistuje";
|
||||
}
|
||||
|
||||
try {
|
||||
if (isDir) {
|
||||
await fs.promises.rm(fullPath, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.unlinkSync(fullPath);
|
||||
await fs.promises.unlink(fullPath);
|
||||
}
|
||||
} catch {
|
||||
return isDir
|
||||
@@ -416,12 +441,17 @@ export class NasFileManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
public moveItem(
|
||||
public async moveItem(
|
||||
projectNumber: string,
|
||||
fromPath: string,
|
||||
toPath: string,
|
||||
): string | null {
|
||||
if (fromPath === "" || fromPath === "/") {
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
fromPath === "" ||
|
||||
fromPath === "/" ||
|
||||
fromPath === "." ||
|
||||
fromPath === "./"
|
||||
) {
|
||||
return "Nelze přesunout kořenovou složku";
|
||||
}
|
||||
|
||||
@@ -432,7 +462,9 @@ export class NasFileManager {
|
||||
return "Neplatná cesta";
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullFrom)) {
|
||||
try {
|
||||
await fs.promises.stat(fullFrom);
|
||||
} catch {
|
||||
return "Zdrojový soubor neexistuje";
|
||||
}
|
||||
|
||||
@@ -441,8 +473,13 @@ export class NasFileManager {
|
||||
fullFrom.replace(/\\/g, "/").toLowerCase() ===
|
||||
fullTo.replace(/\\/g, "/").toLowerCase();
|
||||
|
||||
if (fs.existsSync(fullTo) && !sameFile) {
|
||||
return "Cílový soubor již existuje";
|
||||
if (!sameFile) {
|
||||
try {
|
||||
await fs.promises.stat(fullTo);
|
||||
return "Cílový soubor již existuje";
|
||||
} catch {
|
||||
// target does not exist, continue
|
||||
}
|
||||
}
|
||||
|
||||
const targetName = path.basename(toPath);
|
||||
@@ -451,7 +488,7 @@ export class NasFileManager {
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(fullFrom, fullTo);
|
||||
await fs.promises.rename(fullFrom, fullTo);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
@@ -466,17 +503,22 @@ export class NasFileManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
public createFolder(
|
||||
public async createFolder(
|
||||
projectNumber: string,
|
||||
subPath: string,
|
||||
folderName: string,
|
||||
): string | null {
|
||||
): Promise<string | null> {
|
||||
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
||||
if (
|
||||
dirPath === null ||
|
||||
!fs.existsSync(dirPath) ||
|
||||
!fs.statSync(dirPath).isDirectory()
|
||||
) {
|
||||
if (dirPath === null) {
|
||||
return "Nadřazená složka neexistuje";
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(dirPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return "Nadřazená složka neexistuje";
|
||||
}
|
||||
} catch {
|
||||
return "Nadřazená složka neexistuje";
|
||||
}
|
||||
|
||||
@@ -486,12 +528,15 @@ export class NasFileManager {
|
||||
}
|
||||
|
||||
const newPath = dirPath + "/" + safeName;
|
||||
if (fs.existsSync(newPath)) {
|
||||
try {
|
||||
await fs.promises.stat(newPath);
|
||||
return "Složka s tímto názvem již existuje";
|
||||
} catch {
|
||||
// does not exist, continue
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(newPath, { mode: 0o775 });
|
||||
await fs.promises.mkdir(newPath, { mode: 0o775 });
|
||||
} catch {
|
||||
return "Nepodařilo se vytvořit složku";
|
||||
}
|
||||
@@ -572,6 +617,11 @@ export class NasFileManager {
|
||||
}
|
||||
|
||||
const normalized = subPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
|
||||
// Reject explicit current-directory references (defense-in-depth for destructive ops)
|
||||
if (normalized === "." || normalized === "./") {
|
||||
return null;
|
||||
}
|
||||
const candidate = path.resolve(folderPath, normalized).replace(/\\/g, "/");
|
||||
|
||||
// Verify candidate is within project folder
|
||||
|
||||
@@ -68,11 +68,11 @@ class NasFinancialsManager {
|
||||
if (!dir) return null;
|
||||
|
||||
const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf";
|
||||
const fullPath = path.join(dir, safeName);
|
||||
const fullPath = this.uniquePath(dir, safeName);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(fullPath, pdfBuffer);
|
||||
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${safeName}`;
|
||||
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -125,12 +125,12 @@ class NasFinancialsManager {
|
||||
let safeName = this.sanitizeFilename(originalName);
|
||||
if (!safeName) safeName = "file";
|
||||
|
||||
const fullPath = path.join(dir, safeName);
|
||||
const fullPath = this.uniquePath(dir, safeName);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(fullPath, fileBuffer);
|
||||
return {
|
||||
filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`,
|
||||
filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
|
||||
@@ -126,7 +126,7 @@ async function releaseSequence(type: string, year: number) {
|
||||
}
|
||||
|
||||
/** Verify a shared number is not already used by an order or project. */
|
||||
async function isSharedNumberTaken(number: string): Promise<boolean> {
|
||||
export async function isSharedNumberTaken(number: string): Promise<boolean> {
|
||||
const [existingOrder, existingProject] = await Promise.all([
|
||||
prisma.orders.findFirst({ where: { order_number: number } }),
|
||||
prisma.projects.findFirst({ where: { project_number: number } }),
|
||||
@@ -143,13 +143,21 @@ async function isInvoiceNumberTaken(number: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/** Verify an offer/quotation number is not already used. */
|
||||
async function isOfferNumberTaken(number: string): Promise<boolean> {
|
||||
export async function isOfferNumberTaken(number: string): Promise<boolean> {
|
||||
const existing = await prisma.quotations.findFirst({
|
||||
where: { quotation_number: number },
|
||||
});
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
/** Verify an order number is not already used. */
|
||||
export async function isOrderNumberTaken(number: string): Promise<boolean> {
|
||||
const existing = await prisma.orders.findFirst({
|
||||
where: { order_number: number },
|
||||
});
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next offer/quotation number (consumes sequence).
|
||||
* Verifies uniqueness against the quotations table; retries if taken.
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
generateOfferNumber,
|
||||
previewOfferNumber,
|
||||
releaseOfferNumber,
|
||||
isOfferNumberTaken,
|
||||
} from "./numbering.service";
|
||||
|
||||
interface QuotationItemInput {
|
||||
@@ -142,53 +143,64 @@ export async function createOffer(body: Record<string, any>) {
|
||||
? String(body.quotation_number)
|
||||
: await generateOfferNumber();
|
||||
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: quotationNumber,
|
||||
project_code: body.project_code ? String(body.project_code) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
valid_until: body.valid_until ? new Date(String(body.valid_until)) : null,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
|
||||
status: body.status ? String(body.status) : "active",
|
||||
scope_title: body.scope_title ? String(body.scope_title) : null,
|
||||
scope_description: body.scope_description
|
||||
? String(body.scope_description)
|
||||
: null,
|
||||
},
|
||||
if (body.quotation_number !== undefined && body.quotation_number !== null) {
|
||||
const taken = await isOfferNumberTaken(String(body.quotation_number));
|
||||
if (taken) {
|
||||
return { error: "Číslo nabídky je již použito", status: 400 } as const;
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const quotation = await tx.quotations.create({
|
||||
data: {
|
||||
quotation_number: quotationNumber,
|
||||
project_code: body.project_code ? String(body.project_code) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
valid_until: body.valid_until
|
||||
? new Date(String(body.valid_until))
|
||||
: null,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
|
||||
status: body.status ? String(body.status) : "active",
|
||||
scope_title: body.scope_title ? String(body.scope_title) : null,
|
||||
scope_description: body.scope_description
|
||||
? String(body.scope_description)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.quotation_items.createMany({
|
||||
data: (body.items as QuotationItemInput[]).map((item, i) => ({
|
||||
quotation_id: quotation.id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
is_included_in_total: item.is_included_in_total !== false,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await tx.scope_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
quotation_id: quotation.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return quotation;
|
||||
});
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await prisma.quotation_items.createMany({
|
||||
data: (body.items as QuotationItemInput[]).map((item, i) => ({
|
||||
quotation_id: quotation.id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
is_included_in_total: item.is_included_in_total !== false,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await prisma.scope_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
quotation_id: quotation.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return quotation;
|
||||
}
|
||||
|
||||
export async function updateOffer(id: number, body: Record<string, any>) {
|
||||
@@ -204,55 +216,51 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
||||
return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
|
||||
}
|
||||
|
||||
await prisma.quotations.update({
|
||||
where: { id },
|
||||
data: {
|
||||
customer_id:
|
||||
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
||||
valid_until:
|
||||
body.valid_until !== undefined
|
||||
? body.valid_until
|
||||
? new Date(String(body.valid_until))
|
||||
: null
|
||||
: undefined,
|
||||
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
||||
language: body.language !== undefined ? String(body.language) : undefined,
|
||||
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
|
||||
apply_vat:
|
||||
body.apply_vat !== undefined
|
||||
? body.apply_vat === true ||
|
||||
body.apply_vat === 1 ||
|
||||
body.apply_vat === "1"
|
||||
: undefined,
|
||||
exchange_rate:
|
||||
body.exchange_rate !== undefined
|
||||
? Number(body.exchange_rate)
|
||||
: undefined,
|
||||
status: body.status !== undefined ? String(body.status) : undefined,
|
||||
project_code:
|
||||
body.project_code !== undefined
|
||||
? body.project_code
|
||||
? String(body.project_code)
|
||||
: null
|
||||
: undefined,
|
||||
scope_title:
|
||||
body.scope_title !== undefined
|
||||
? body.scope_title
|
||||
? String(body.scope_title)
|
||||
: null
|
||||
: undefined,
|
||||
scope_description:
|
||||
body.scope_description !== undefined
|
||||
? body.scope_description
|
||||
? String(body.scope_description)
|
||||
: null
|
||||
: undefined,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
const data = {
|
||||
customer_id:
|
||||
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
||||
valid_until:
|
||||
body.valid_until !== undefined
|
||||
? body.valid_until
|
||||
? new Date(String(body.valid_until))
|
||||
: null
|
||||
: undefined,
|
||||
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
||||
language: body.language !== undefined ? String(body.language) : undefined,
|
||||
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
|
||||
apply_vat:
|
||||
body.apply_vat !== undefined
|
||||
? body.apply_vat === true ||
|
||||
body.apply_vat === 1 ||
|
||||
body.apply_vat === "1"
|
||||
: undefined,
|
||||
exchange_rate:
|
||||
body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
|
||||
status: body.status !== undefined ? String(body.status) : undefined,
|
||||
project_code:
|
||||
body.project_code !== undefined
|
||||
? body.project_code
|
||||
? String(body.project_code)
|
||||
: null
|
||||
: undefined,
|
||||
scope_title:
|
||||
body.scope_title !== undefined
|
||||
? body.scope_title
|
||||
? String(body.scope_title)
|
||||
: null
|
||||
: undefined,
|
||||
scope_description:
|
||||
body.scope_description !== undefined
|
||||
? body.scope_description
|
||||
? String(body.scope_description)
|
||||
: null
|
||||
: undefined,
|
||||
modified_at: new Date(),
|
||||
};
|
||||
|
||||
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.quotations.update({ where: { id }, data });
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
|
||||
await tx.quotation_items.createMany({
|
||||
@@ -281,6 +289,8 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await prisma.quotations.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
return { id, quotation_number: existing.quotation_number };
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
generateSharedNumber,
|
||||
previewSharedNumber,
|
||||
releaseSharedNumber,
|
||||
isOrderNumberTaken,
|
||||
} from "./numbering.service";
|
||||
|
||||
interface OrderItemInput {
|
||||
@@ -290,52 +291,61 @@ export async function createOrder(body: CreateOrderData) {
|
||||
? String(body.order_number)
|
||||
: await generateSharedNumber();
|
||||
|
||||
const order = await prisma.orders.create({
|
||||
data: {
|
||||
order_number: orderNumber,
|
||||
customer_order_number: body.customer_order_number ?? null,
|
||||
quotation_id: body.quotation_id ?? null,
|
||||
customer_id: body.customer_id ?? null,
|
||||
status: body.status,
|
||||
currency: body.currency,
|
||||
language: body.language,
|
||||
vat_rate: body.vat_rate,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
exchange_rate: body.exchange_rate,
|
||||
scope_title: body.scope_title ?? null,
|
||||
scope_description: body.scope_description ?? null,
|
||||
notes: body.notes ?? null,
|
||||
},
|
||||
if (body.order_number !== undefined && body.order_number !== null) {
|
||||
const taken = await isOrderNumberTaken(String(body.order_number));
|
||||
if (taken) {
|
||||
return { error: "Číslo objednávky je již použito", status: 400 } as const;
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const order = await tx.orders.create({
|
||||
data: {
|
||||
order_number: orderNumber,
|
||||
customer_order_number: body.customer_order_number ?? null,
|
||||
quotation_id: body.quotation_id ?? null,
|
||||
customer_id: body.customer_id ?? null,
|
||||
status: body.status,
|
||||
currency: body.currency,
|
||||
language: body.language,
|
||||
vat_rate: body.vat_rate,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
exchange_rate: body.exchange_rate,
|
||||
scope_title: body.scope_title ?? null,
|
||||
scope_description: body.scope_description ?? null,
|
||||
notes: body.notes ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.order_items.createMany({
|
||||
data: body.items.map((item, i) => ({
|
||||
order_id: order.id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
is_included_in_total: item.is_included_in_total !== false,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await tx.order_sections.createMany({
|
||||
data: body.sections.map((s, i) => ({
|
||||
order_id: order.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { id: order.id, order_number: order.order_number };
|
||||
});
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await prisma.order_items.createMany({
|
||||
data: body.items.map((item, i) => ({
|
||||
order_id: order.id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
is_included_in_total: item.is_included_in_total !== false,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await prisma.order_sections.createMany({
|
||||
data: body.sections.map((s, i) => ({
|
||||
order_id: order.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { id: order.id, order_number: order.order_number };
|
||||
}
|
||||
|
||||
interface UpdateOrderData {
|
||||
@@ -393,24 +403,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
data.apply_vat =
|
||||
body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1";
|
||||
|
||||
await prisma.orders.update({ where: { id }, data });
|
||||
|
||||
// Sync project status when order status changes (matching PHP)
|
||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||
const statusMap: Record<string, string> = {
|
||||
v_realizaci: "aktivni",
|
||||
dokoncena: "dokonceny",
|
||||
stornovana: "zruseny",
|
||||
};
|
||||
const projectStatus = statusMap[String(body.status)];
|
||||
if (projectStatus) {
|
||||
await prisma.projects.updateMany({
|
||||
where: { order_id: id },
|
||||
data: { status: projectStatus },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
|
||||
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
|
||||
return {
|
||||
@@ -419,6 +411,24 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
} as const;
|
||||
}
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.orders.update({ where: { id }, data });
|
||||
|
||||
// Sync project status when order status changes (matching PHP)
|
||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||
const statusMap: Record<string, string> = {
|
||||
v_realizaci: "aktivni",
|
||||
dokoncena: "dokonceny",
|
||||
stornovana: "zruseny",
|
||||
};
|
||||
const projectStatus = statusMap[String(body.status)];
|
||||
if (projectStatus) {
|
||||
await tx.projects.updateMany({
|
||||
where: { order_id: id },
|
||||
data: { status: projectStatus },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.order_items.deleteMany({ where: { order_id: id } });
|
||||
await tx.order_items.createMany({
|
||||
@@ -447,6 +457,24 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await prisma.orders.update({ where: { id }, data });
|
||||
|
||||
// Sync project status when order status changes (matching PHP)
|
||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||
const statusMap: Record<string, string> = {
|
||||
v_realizaci: "aktivni",
|
||||
dokoncena: "dokonceny",
|
||||
stornovana: "zruseny",
|
||||
};
|
||||
const projectStatus = statusMap[String(body.status)];
|
||||
if (projectStatus) {
|
||||
await prisma.projects.updateMany({
|
||||
where: { order_id: id },
|
||||
data: { status: projectStatus },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { data: { id, order_number: existing.order_number } };
|
||||
@@ -478,17 +506,22 @@ export async function deleteOrder(id: number) {
|
||||
|
||||
await prisma.orders.delete({ where: { id } });
|
||||
|
||||
const releasedYears = new Set<number>();
|
||||
const year = existing.created_at
|
||||
? new Date(existing.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseSharedNumber(year);
|
||||
releasedYears.add(year);
|
||||
|
||||
// Release the linked project's shared number(s) too
|
||||
for (const p of linkedProjects) {
|
||||
const pYear = p.created_at
|
||||
? new Date(p.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseSharedNumber(pYear);
|
||||
if (!releasedYears.has(pYear)) {
|
||||
await releaseSharedNumber(pYear);
|
||||
releasedYears.add(pYear);
|
||||
}
|
||||
}
|
||||
|
||||
return { data: { id, order_number: existing.order_number } };
|
||||
|
||||
@@ -56,13 +56,13 @@ export async function listProjects(params: ListProjectsParams) {
|
||||
prisma.projects.count({ where }),
|
||||
]);
|
||||
|
||||
const enriched = projects.map((p) => ({
|
||||
const enriched = projects.map(({ customers, users, orders, ...p }) => ({
|
||||
...p,
|
||||
customer_name: p.customers?.name || null,
|
||||
responsible_user_name: p.users
|
||||
? `${p.users.first_name} ${p.users.last_name}`.trim()
|
||||
customer_name: customers?.name || null,
|
||||
responsible_user_name: users
|
||||
? `${users.first_name} ${users.last_name}`.trim()
|
||||
: null,
|
||||
order_number: p.orders?.order_number || null,
|
||||
order_number: orders?.order_number || null,
|
||||
}));
|
||||
|
||||
return { data: enriched, total, page, limit };
|
||||
@@ -72,10 +72,10 @@ export async function getProject(id: number) {
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
users: true,
|
||||
quotations: true,
|
||||
orders: true,
|
||||
customers: { select: { id: true, name: true } },
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
quotations: { select: { id: true, quotation_number: true } },
|
||||
orders: { select: { id: true, order_number: true, status: true } },
|
||||
project_notes: { orderBy: { created_at: "desc" } },
|
||||
},
|
||||
});
|
||||
@@ -96,22 +96,33 @@ export async function getProject(id: number) {
|
||||
};
|
||||
}
|
||||
|
||||
import { isSharedNumberTaken } from "./numbering.service";
|
||||
|
||||
export async function createProject(body: Record<string, any>) {
|
||||
const projectNumber =
|
||||
body.project_number !== undefined && body.project_number !== null
|
||||
? String(body.project_number)
|
||||
: await generateSharedNumber();
|
||||
|
||||
if (body.project_number !== undefined && body.project_number !== null) {
|
||||
const taken = await isSharedNumberTaken(String(body.project_number));
|
||||
if (taken) {
|
||||
return { error: "Číslo projektu je již použito", status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
const project = await prisma.projects.create({
|
||||
data: {
|
||||
project_number: projectNumber,
|
||||
name: body.name ? String(body.name) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
responsible_user_id: body.responsible_user_id
|
||||
? Number(body.responsible_user_id)
|
||||
: null,
|
||||
quotation_id: body.quotation_id ? Number(body.quotation_id) : null,
|
||||
order_id: body.order_id ? Number(body.order_id) : null,
|
||||
customer_id: body.customer_id != null ? Number(body.customer_id) : null,
|
||||
responsible_user_id:
|
||||
body.responsible_user_id != null
|
||||
? Number(body.responsible_user_id)
|
||||
: null,
|
||||
quotation_id:
|
||||
body.quotation_id != null ? Number(body.quotation_id) : null,
|
||||
order_id: body.order_id != null ? Number(body.order_id) : null,
|
||||
status: body.status ? String(body.status) : "aktivni",
|
||||
start_date: body.start_date ? new Date(String(body.start_date)) : null,
|
||||
end_date: body.end_date ? new Date(String(body.end_date)) : null,
|
||||
@@ -145,15 +156,18 @@ export async function updateProject(id: number, body: Record<string, any>) {
|
||||
for (const f of strFields)
|
||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||
if (body.customer_id !== undefined)
|
||||
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
||||
data.customer_id =
|
||||
body.customer_id != null ? Number(body.customer_id) : null;
|
||||
if (body.responsible_user_id !== undefined)
|
||||
data.responsible_user_id = body.responsible_user_id
|
||||
? Number(body.responsible_user_id)
|
||||
: null;
|
||||
data.responsible_user_id =
|
||||
body.responsible_user_id != null
|
||||
? Number(body.responsible_user_id)
|
||||
: null;
|
||||
if (body.quotation_id !== undefined)
|
||||
data.quotation_id = body.quotation_id ? Number(body.quotation_id) : null;
|
||||
data.quotation_id =
|
||||
body.quotation_id != null ? Number(body.quotation_id) : null;
|
||||
if (body.order_id !== undefined)
|
||||
data.order_id = body.order_id ? Number(body.order_id) : null;
|
||||
data.order_id = body.order_id != null ? Number(body.order_id) : null;
|
||||
if (body.start_date !== undefined)
|
||||
data.start_date = body.start_date
|
||||
? new Date(String(body.start_date))
|
||||
@@ -161,7 +175,7 @@ export async function updateProject(id: number, body: Record<string, any>) {
|
||||
if (body.end_date !== undefined)
|
||||
data.end_date = body.end_date ? new Date(String(body.end_date)) : null;
|
||||
|
||||
await prisma.projects.update({ where: { id }, data });
|
||||
const updated = await prisma.projects.update({ where: { id }, data });
|
||||
|
||||
if (
|
||||
body.name !== undefined &&
|
||||
@@ -175,7 +189,7 @@ export async function updateProject(id: number, body: Record<string, any>) {
|
||||
);
|
||||
}
|
||||
|
||||
return existing;
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteProject(id: number, deleteFiles: boolean = false) {
|
||||
|
||||
@@ -91,7 +91,10 @@ export async function getUser(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUser(data: CreateUserData) {
|
||||
export async function createUser(
|
||||
data: CreateUserData,
|
||||
callerRoleName?: string,
|
||||
) {
|
||||
const username = data.username.trim();
|
||||
const email = data.email.trim();
|
||||
const firstName = data.first_name.trim();
|
||||
@@ -113,6 +116,15 @@ export async function createUser(data: CreateUserData) {
|
||||
return { error: "E-mail již existuje", status: 409 } as const;
|
||||
}
|
||||
|
||||
if (data.role_id) {
|
||||
const targetRole = await prisma.roles.findUnique({
|
||||
where: { id: Number(data.role_id) },
|
||||
});
|
||||
if (targetRole?.name === "admin" && callerRoleName !== "admin") {
|
||||
return { error: "Nelze přiřadit roli admin", status: 403 } as const;
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(
|
||||
data.password,
|
||||
config.security.bcryptCost,
|
||||
@@ -133,12 +145,71 @@ export async function createUser(data: CreateUserData) {
|
||||
return { user } as const;
|
||||
}
|
||||
|
||||
export async function updateUser(id: number, body: UpdateUserData) {
|
||||
export async function updateUser(
|
||||
id: number,
|
||||
body: UpdateUserData,
|
||||
callerRoleName?: string,
|
||||
) {
|
||||
const existing = await prisma.users.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return { error: "Uživatel nenalezen", status: 404 } as const;
|
||||
}
|
||||
|
||||
if (body.role_id !== undefined) {
|
||||
if (body.role_id) {
|
||||
const targetRole = await prisma.roles.findUnique({
|
||||
where: { id: Number(body.role_id) },
|
||||
});
|
||||
if (targetRole?.name === "admin" && callerRoleName !== "admin") {
|
||||
return { error: "Nelze přiřadit roli admin", status: 403 } as const;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
existing.role_id !== null &&
|
||||
Number(body.role_id) !== existing.role_id
|
||||
) {
|
||||
const currentRole = await prisma.roles.findUnique({
|
||||
where: { id: existing.role_id },
|
||||
});
|
||||
if (currentRole?.name === "admin") {
|
||||
const adminRole = await prisma.roles.findFirst({
|
||||
where: { name: "admin" },
|
||||
});
|
||||
if (adminRole) {
|
||||
const activeAdminCount = await prisma.users.count({
|
||||
where: { role_id: adminRole.id, is_active: true },
|
||||
});
|
||||
if (activeAdminCount <= 1) {
|
||||
return {
|
||||
error: "Nelze odebrat roli poslednímu aktivnímu administrátorovi",
|
||||
status: 400,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (body.is_active !== undefined && !body.is_active) {
|
||||
if (existing.role_id !== null && existing.is_active) {
|
||||
const adminRole = await prisma.roles.findFirst({
|
||||
where: { name: "admin" },
|
||||
});
|
||||
if (adminRole && existing.role_id === adminRole.id) {
|
||||
const activeAdminCount = await prisma.users.count({
|
||||
where: { role_id: adminRole.id, is_active: true },
|
||||
});
|
||||
if (activeAdminCount <= 1) {
|
||||
return {
|
||||
error: "Nelze deaktivovat posledního aktivního administrátora",
|
||||
status: 400,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (body.username !== undefined) {
|
||||
|
||||
@@ -128,4 +128,5 @@ export type EntityType =
|
||||
| "bank_account"
|
||||
| "company_settings"
|
||||
| "leave_balance"
|
||||
| "project_file";
|
||||
| "project_file"
|
||||
| "audit_logs";
|
||||
|
||||
@@ -40,7 +40,11 @@ export async function htmlToPdf(html: string): Promise<Buffer> {
|
||||
});
|
||||
return Buffer.from(pdf);
|
||||
} finally {
|
||||
await page.close();
|
||||
try {
|
||||
await page.close();
|
||||
} catch {
|
||||
await closeBrowser();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@ export default defineConfig({
|
||||
setupFiles: ["./src/__tests__/setup.ts"],
|
||||
testTimeout: 15000,
|
||||
hookTimeout: 15000,
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
exclude: ["dist/**", "node_modules/**", ".claude/**"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user