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:
BOHA
2026-04-24 00:58:35 +02:00
parent 122eee175e
commit 528e55991b
57 changed files with 2355 additions and 1010 deletions

533
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.5.1", "version": "1.5.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "1.5.1", "version": "1.5.3",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -19,6 +19,7 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
@@ -27,6 +28,7 @@
"file-type": "^16.5.4", "file-type": "^16.5.4",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"hi-base32": "^0.5.1", "hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
@@ -64,6 +66,53 @@
"vitest": "^4.1.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -87,6 +136,152 @@
"node": ">=6.9.0" "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": { "node_modules/@dnd-kit/accessibility": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -630,6 +825,23 @@
"node": ">=18" "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": { "node_modules/@fastify/accept-negotiator": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
@@ -1513,6 +1725,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/jsonwebtoken": {
"version": "9.0.10", "version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
@@ -1562,7 +1792,6 @@
"version": "25.5.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
@@ -1639,6 +1868,12 @@
"@types/superagent": "^8.1.0" "@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": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2088,6 +2323,15 @@
"bcrypt": "bin/bcrypt" "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": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2510,6 +2767,19 @@
"node": ">= 14" "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": { "node_modules/date-fns": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -2546,6 +2816,12 @@
"node": ">=0.10.0" "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": { "node_modules/deepmerge-ts": {
"version": "7.1.5", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
@@ -2721,6 +2997,18 @@
"once": "^1.4.0" "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": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "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==", "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==",
"license": "MIT" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3617,6 +3917,12 @@
"node": ">=8" "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": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -3644,6 +3950,70 @@
"js-yaml": "bin/js-yaml.js" "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": { "node_modules/json-parse-even-better-errors": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "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": ">= 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": { "node_modules/methods": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -4477,6 +4853,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4741,6 +5129,15 @@
"once": "^1.3.1" "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": { "node_modules/puppeteer": {
"version": "24.40.0", "version": "24.40.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz",
@@ -5257,6 +5654,18 @@
"node": ">=10" "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": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -5469,7 +5878,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5635,6 +6043,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "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": { "node_modules/tabbable": {
"version": "6.4.0", "version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
@@ -5768,6 +6182,24 @@
"node": ">=14.0.0" "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": { "node_modules/toad-cache": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
@@ -5803,6 +6235,30 @@
"url": "https://github.com/sponsors/Borewit" "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": { "node_modules/tree-kill": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -5859,11 +6315,19 @@
"node": ">=14.17" "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": { "node_modules/undici-types": {
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "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": { "node_modules/webdriver-bidi-protocol": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
"license": "Apache-2.0" "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": { "node_modules/which-module": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -34,6 +34,7 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
@@ -42,6 +43,7 @@
"file-type": "^16.5.4", "file-type": "^16.5.4",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"hi-base32": "^0.5.1", "hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",

View File

@@ -32,7 +32,7 @@ model attendance {
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1") users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[] 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([user_id, departure_time], map: "idx_attendance_user_departure")
@@index([project_id], map: "idx_project_id") @@index([project_id], map: "idx_project_id")
} }
@@ -46,6 +46,7 @@ model attendance_project_logs {
hours Int? @db.UnsignedInt hours Int? @db.UnsignedInt
minutes Int? @db.UnsignedInt minutes Int? @db.UnsignedInt
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction) 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([attendance_id], map: "idx_attendance_project_logs_aid")
@@index([project_id], map: "idx_project_id") @@index([project_id], map: "idx_project_id")
@@ -104,7 +105,7 @@ model company_settings {
quotation_prefix String? @db.VarChar(20) quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10) default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) 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) modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
sync_version Int? @default(0) sync_version Int? @default(0)
@@ -165,7 +166,7 @@ model invoice_items {
model invoices { model invoices {
id Int @id @default(autoincrement()) 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? order_id Int?
customer_id Int? customer_id Int?
status String? @default("issued") @db.VarChar(30) status String? @default("issued") @db.VarChar(30)
@@ -288,7 +289,7 @@ model order_sections {
model orders { model orders {
id Int @id @default(autoincrement()) 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) customer_order_number String? @db.VarChar(100)
attachment_data Bytes? attachment_data Bytes?
attachment_name String? @db.VarChar(255) attachment_name String? @db.VarChar(255)
@@ -340,7 +341,7 @@ model project_notes {
model projects { model projects {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
project_number String? @db.VarChar(50) project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255) name String? @db.VarChar(255)
customer_id Int? customer_id Int?
responsible_user_id Int? responsible_user_id Int?
@@ -352,6 +353,7 @@ model projects {
notes String? @db.Text notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[] project_notes project_notes[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user") 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") customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
@@ -385,7 +387,7 @@ model quotation_items {
model quotations { model quotations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
quotation_number String? @db.VarChar(50) quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(50) project_code String? @db.VarChar(50)
customer_id Int? customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
@@ -434,7 +436,7 @@ model received_invoices {
file_mime String? @db.VarChar(100) file_mime String? @db.VarChar(100)
file_size Int? @db.UnsignedInt file_size Int? @db.UnsignedInt
notes String? @db.Text notes String? @db.Text
uploaded_by Int? @db.UnsignedInt uploaded_by Int?
created_at DateTime @default(now()) @db.DateTime(0) created_at DateTime @default(now()) @db.DateTime(0)
modified_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 /// 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 { model refresh_tokens {
id Int @id @default(autoincrement()) @db.UnsignedInt 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) token_hash String @unique(map: "token_hash") @db.VarChar(64)
expires_at DateTime @db.DateTime(0) expires_at DateTime @db.DateTime(0)
replaced_at DateTime? @db.DateTime(0) replaced_at DateTime? @db.DateTime(0)

View File

@@ -91,7 +91,7 @@ function NativeInput({
} }
interface AdminDatePickerProps { interface AdminDatePickerProps {
mode?: "date" | "month" | "datetime" | "time"; mode?: "date" | "month" | "time";
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
minDate?: string; minDate?: string;

View File

@@ -57,13 +57,21 @@ export default function BulkAttendanceModal({
/> />
<motion.div <motion.div
className="admin-modal admin-modal-lg" 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 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-header"> <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 <p
style={{ style={{
color: "var(--text-secondary)", color: "var(--text-secondary)",

View File

@@ -39,6 +39,9 @@ export default function ConfirmModal({
<div className="admin-modal-backdrop" onClick={onClose} /> <div className="admin-modal-backdrop" onClick={onClose} />
<motion.div <motion.div
className="admin-modal admin-confirm-modal" className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} 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" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
</div> </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> <p className="admin-confirm-message">{message}</p>
</div> </div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">

View File

@@ -109,13 +109,19 @@ export default function OrderConfirmationModal({
className={ className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal" 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 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title"> <h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber} Potvrzení objednávky {orderNumber}
</h2> </h2>
</div> </div>

View File

@@ -197,6 +197,7 @@ export default function ProjectFileManager({
}: ProjectFileManagerProps) { }: ProjectFileManagerProps) {
const alert = useAlert(); const alert = useAlert();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isCancelling = useRef(false);
const [items, setItems] = useState<FileItem[]>([]); const [items, setItems] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -768,10 +769,26 @@ export default function ProjectFileManager({
}} }}
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleRename(item); if (e.key === "Enter") {
if (e.key === "Escape") setRenamingItem(null); 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 <FileNameCell

View File

@@ -251,13 +251,16 @@ export default function ShiftFormModal({
<div className="admin-modal-backdrop" onClick={onClose} /> <div className="admin-modal-backdrop" onClick={onClose} />
<motion.div <motion.div
className="admin-modal admin-modal-lg" 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 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-header"> <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"} {isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2> </h2>
{!isCreate && editingRecord && ( {!isCreate && editingRecord && (

View File

@@ -5,6 +5,7 @@ import {
useCallback, useCallback,
useMemo, useMemo,
useRef, useRef,
useEffect,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@@ -39,6 +40,15 @@ export function AlertProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const counterRef = useRef(0); 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( const addAlert = useCallback(
(message: string, type = "success", duration = 4000) => { (message: string, type = "success", duration = 4000) => {
const id = `${Date.now()}-${counterRef.current++}`; const id = `${Date.now()}-${counterRef.current++}`;
@@ -47,7 +57,8 @@ export function AlertProvider({ children }: { children: ReactNode }) {
{ id, message, type: type as Alert["type"] }, { id, message, type: type as Alert["type"] },
]); ]);
if (duration > 0) { if (duration > 0) {
setTimeout(() => removeAlert(id), duration); const timeoutId = setTimeout(() => removeAlert(id), duration);
timeoutsRef.current.add(timeoutId);
} }
return id; return id;
}, },

View File

@@ -84,32 +84,35 @@ function mapUser(u: Record<string, unknown> | null): User | null {
} as User; } 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 }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(cachedUser); const accessTokenRef = useRef<string | null>(null);
const [loading, setLoading] = useState(!sessionFetched); 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 [error, setError] = useState<string | null>(null);
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
cachedUser = user; cachedUserRef.current = user;
}, [user]); }, [user]);
const getAccessTokenFn = useCallback((): string | null => { const getAccessTokenFn = useCallback((): string | null => {
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null; if (
return accessToken; !tokenExpiresAtRef.current ||
Date.now() > tokenExpiresAtRef.current - 30000
)
return null;
return accessTokenRef.current;
}, []); }, []);
const setAccessTokenFn = useCallback( const setAccessTokenFn = useCallback(
(token: string | null, expiresIn?: number) => { (token: string | null, expiresIn?: number) => {
const ttl = expiresIn ?? 900; // default 15 min matching backend config const ttl = expiresIn ?? 900; // default 15 min matching backend config
accessToken = token; accessTokenRef.current = token;
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null; tokenExpiresAtRef.current = token ? Date.now() + ttl * 1000 : null;
if (refreshTimeoutRef.current) { if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current); clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null; refreshTimeoutRef.current = null;
@@ -126,7 +129,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const silentRefresh = useCallback(async (): Promise<boolean> => { const silentRefresh = useCallback(async (): Promise<boolean> => {
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed // 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> => { const promise = (async (): Promise<boolean> => {
try { try {
@@ -140,21 +144,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
return true; return true;
} }
accessToken = null; accessTokenRef.current = null;
tokenExpiresAt = null; tokenExpiresAtRef.current = null;
setUser(null); setUser(null);
cachedUser = null; cachedUserRef.current = null;
setSessionExpired(); setSessionExpired();
return false; return false;
} catch { } catch {
// Network error — don't kick the user out, just return false // Network error — don't kick the user out, just return false
return false; return false;
} finally { } finally {
silentRefreshInFlight = null; silentRefreshInFlightRef.current = null;
} }
})(); })();
silentRefreshInFlight = promise; silentRefreshInFlightRef.current = promise;
return promise; return promise;
}, [setAccessTokenFn]); }, [setAccessTokenFn]);
@@ -172,12 +176,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
headers, headers,
}); });
if (response.status === 429 || response.status >= 500) if (response.status === 429 || response.status >= 500)
return !!cachedUser; return !!cachedUserRef.current;
const data = await response.json(); const data = await response.json();
if (data.success && data.data?.user) { if (data.success && data.data?.user) {
if (data.data.access_token) setAccessTokenFn(data.data.access_token); if (data.data.access_token) setAccessTokenFn(data.data.access_token);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
return true; return true;
} }
} }
@@ -185,15 +189,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const refreshed = await silentRefresh(); const refreshed = await silentRefresh();
if (refreshed) return true; if (refreshed) return true;
setUser(null); setUser(null);
cachedUser = null; cachedUserRef.current = null;
accessToken = null; accessTokenRef.current = null;
tokenExpiresAt = null; tokenExpiresAtRef.current = null;
return false; return false;
} catch { } catch {
return !!cachedUser; return !!cachedUserRef.current;
} finally { } finally {
setLoading(false); setLoading(false);
sessionFetched = true; sessionFetchedRef.current = true;
} }
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]); }, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
@@ -231,8 +235,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
sessionFetched = true; sessionFetchedRef.current = true;
return { success: true }; return { success: true };
} }
setError(data.error); setError(data.error);
@@ -270,8 +274,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (data.success) { if (data.success) {
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
sessionFetched = true; sessionFetchedRef.current = true;
return { success: true }; return { success: true };
} }
setError(data.error); setError(data.error);
@@ -296,11 +300,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {
accessToken = null; accessTokenRef.current = null;
tokenExpiresAt = null; tokenExpiresAtRef.current = null;
setUser(null); setUser(null);
cachedUser = null; cachedUserRef.current = null;
sessionFetched = false; sessionFetchedRef.current = false;
if (refreshTimeoutRef.current) { if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current); clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null; refreshTimeoutRef.current = null;

View File

@@ -23,8 +23,9 @@ export default function useApiCall() {
abortRef.current = controller; abortRef.current = controller;
try { try {
const { signal: _, ...restOptions } = options;
const response = await apiFetch(url, { const response = await apiFetch(url, {
...options, ...restOptions,
signal: controller.signal, signal: controller.signal,
}); });
const data = await response.json(); const data = await response.json();

View File

@@ -224,11 +224,20 @@ function computeUserTotals(
// Print helpers // Print helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function renderFundStatus(userData: Record<string, any>): string { function renderFundStatus(userData: Record<string, any>): string {
if (userData.overtime > 0) 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) 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>'; return '<span style="color:#16a34a">splněno</span>';
} }
@@ -255,11 +264,11 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
h = 0; h = 0;
m = 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(""); .join("");
} }
return record.project_name || "—"; return escapeHtml(record.project_name || "—");
} }
function buildLeaveSummaryHtml( function buildLeaveSummaryHtml(
@@ -268,15 +277,15 @@ function buildLeaveSummaryHtml(
printData: Record<string, any>, printData: Record<string, any>,
): string { ): string {
const bal = printData.leave_balances[userId]; 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) 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) 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) 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) 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>`; return `<div class="leave-summary">${parts}</div>`;
} }
@@ -299,17 +308,17 @@ function buildUserSectionHtml(
const breakCell = const breakCell =
isLeave || !record.break_start || !record.break_end 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> return `<tr>
<td>${formatDate(record.shift_date)}</td> <td>${escapeHtml(formatDate(record.shift_date))}</td>
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td> <td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></td>
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</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">${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 class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td>
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td> <td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${record.notes || ""}</td> <td>${escapeHtml(record.notes || "")}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
@@ -318,15 +327,15 @@ function buildUserSectionHtml(
userData.fund !== null userData.fund !== null
? `<tr> ? `<tr>
<td colspan="6" class="text-right">Fond měsíce:</td> <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> <td colspan="2">${renderFundStatus(userData)}</td>
</tr>` </tr>`
: ""; : "";
return `<div class="user-section"> return `<div class="user-section">
<div class="user-header"> <div class="user-header">
<h3>${userData.name}</h3> <h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span> <span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div> </div>
${leaveHtml} ${leaveHtml}
<table> <table>
@@ -344,7 +353,7 @@ function buildUserSectionHtml(
<tfoot> <tfoot>
<tr> <tr>
<td colspan="6" class="text-right">Odpracováno:</td> <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> <td colspan="2"></td>
</tr> </tr>
${fundRow} ${fundRow}
@@ -365,7 +374,7 @@ function buildPrintHtml(
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
@@ -428,11 +437,11 @@ function buildPrintHtml(
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" /> <img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
<div class="print-header-text"> <div class="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1> <h1>EVIDENCE DOCHÁZKY</h1>
<div class="company">${companyName}</div> <div class="company">${escapeHtml(companyName)}</div>
</div> </div>
</div> </div>
<div class="print-header-right"> <div class="print-header-right">
<div class="period">${pData.month_name}</div> <div class="period">${escapeHtml(pData.month_name)}</div>
${filterNote} ${filterNote}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div> <div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
</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>' ? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
: ""; : "";
const filterNote = pData.selected_user_name 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( const bodyContent = buildPrintHtml(
pData, pData,

View File

@@ -1,14 +1,16 @@
import { useEffect } from "react"; import { useEffect } from "react";
let activeLocks = 0;
export default function useModalLock(isOpen: boolean): void { export default function useModalLock(isOpen: boolean): void {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = "hidden"; if (activeLocks === 0) document.body.style.overflow = "hidden";
} else { activeLocks++;
document.body.style.overflow = ""; return () => {
activeLocks = Math.max(0, activeLocks - 1);
if (activeLocks === 0) document.body.style.overflow = "";
};
} }
return () => {
document.body.style.overflow = "";
};
}, [isOpen]); }, [isOpen]);
} }

View File

@@ -313,7 +313,7 @@ export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id); const isEdit = Boolean(id);
const keyCounterRef = useRef(0); const keyCounterRef = useRef(1);
const emptyItem = useCallback( const emptyItem = useCallback(
(): InvoiceItem => ({ (): InvoiceItem => ({
_key: `inv-${++keyCounterRef.current}`, _key: `inv-${++keyCounterRef.current}`,
@@ -369,7 +369,16 @@ export default function InvoiceDetail() {
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]); const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [dueDays, setDueDays] = useState(14); 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 [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

View File

@@ -138,8 +138,18 @@ export default function Invoices() {
const [statsLoading, setStatsLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0); const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
const [slideKey, setSlideKey] = useState(0); const [slideKey, setSlideKey] = useState(0);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isCurrentMonth = const isCurrentMonth =
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear(); statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`; const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
@@ -299,9 +309,11 @@ export default function Invoices() {
return; return;
} }
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); if (blobUrlRef.current) {
if (newWindow) newWindow.location.href = url; URL.revokeObjectURL(blobUrlRef.current);
setTimeout(() => URL.revokeObjectURL(url), 60000); }
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch { } catch {
alert.error("Chyba při generování PDF"); alert.error("Chyba při generování PDF");
} finally { } finally {

View File

@@ -55,9 +55,6 @@ interface OfferItem {
is_included_in_total: boolean; is_included_in_total: boolean;
} }
let _itemKeyCounter = 0;
const nextItemKey = () => `item-${++_itemKeyCounter}`;
interface ScopeSection { interface ScopeSection {
title: string; title: string;
title_cz: string; title_cz: string;
@@ -113,16 +110,6 @@ const emptyScopeSection = (): ScopeSection => ({
content: "", content: "",
}); });
const emptyItem = (): OfferItem => ({
_key: nextItemKey(),
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
});
function SortableItemRow({ function SortableItemRow({
item, item,
index, index,
@@ -288,11 +275,25 @@ export default function OfferDetail() {
useSensor(KeyboardSensor), 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 [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({}); const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm); const [form, setForm] = useState<OfferForm>(emptyForm);
const [items, setItems] = useState<OfferItem[]>([emptyItem()]); const [items, setItems] = useState<OfferItem[]>(() => [emptyItem()]);
const [sections, setSections] = useState<ScopeSection[]>([]); const [sections, setSections] = useState<ScopeSection[]>([]);
const [scopeTemplates, setScopeTemplates] = useState< const [scopeTemplates, setScopeTemplates] = useState<
Array<{ Array<{
@@ -397,7 +398,10 @@ export default function OfferDetail() {
}); });
setItems( setItems(
d.items?.length d.items?.length
? d.items.map((it: any) => ({ ...it, _key: nextItemKey() })) ? d.items.map((it: any) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
: [emptyItem()], : [emptyItem()],
); );
setSections( setSections(

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@@ -63,6 +63,16 @@ export default function Offers() {
quotation: Quotation | null; quotation: Quotation | null;
}>({ show: false, quotation: null }); }>({ show: false, quotation: null });
const [invalidating, setInvalidating] = useState(false); 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 [duplicating, setDuplicating] = useState<number | null>(null);
const [pdfLoading, setPdfLoading] = useState<number | null>(null); const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [creatingOrder, setCreatingOrder] = useState<number | null>(null); const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
@@ -237,9 +247,11 @@ export default function Offers() {
return; return;
} }
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); if (blobUrlRef.current) {
if (newWindow) newWindow.location.href = url; URL.revokeObjectURL(blobUrlRef.current);
setTimeout(() => URL.revokeObjectURL(url), 60000); }
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba připojení"); alert.error("Chyba připojení");

View File

@@ -76,6 +76,7 @@ export default function Settings() {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [users, setUsers] = useState<{ role_id: number }[]>([]);
const [, setAllPermissions] = useState<Permission[]>([]); const [, setAllPermissions] = useState<Permission[]>([]);
const [permissionGroups, setPermissionGroups] = useState< const [permissionGroups, setPermissionGroups] = useState<
Record<string, Permission[]> Record<string, Permission[]>
@@ -161,12 +162,14 @@ export default function Settings() {
return; return;
} }
try { try {
const [rolesRes, permsRes] = await Promise.all([ const [rolesRes, permsRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`), apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`), apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]); ]);
const rolesResult = await rolesRes.json(); const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json(); const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
if (rolesResult.success) { if (rolesResult.success) {
setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []); setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []);
@@ -188,6 +191,10 @@ export default function Settings() {
} }
setPermissionGroups(groups); setPermissionGroups(groups);
} }
if (usersResult.success) {
setUsers(Array.isArray(usersResult.data) ? usersResult.data : []);
}
} catch { } catch {
alert.error("Chyba připojení"); alert.error("Chyba připojení");
} finally { } finally {
@@ -808,7 +815,7 @@ export default function Settings() {
</td> </td>
<td> <td>
<span className="admin-badge admin-badge-secondary"> <span className="admin-badge admin-badge-secondary">
{0} {users.filter((u) => u.role_id === role.id).length}
</span> </span>
</td> </td>
<td> <td>
@@ -838,16 +845,21 @@ export default function Settings() {
} }
className="admin-btn-icon danger" className="admin-btn-icon danger"
title={ title={
0 > 0 users.filter((u) => u.role_id === role.id)
.length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
aria-label={ aria-label={
0 > 0 users.filter((u) => u.role_id === role.id)
.length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
disabled={0 > 0} disabled={
users.filter((u) => u.role_id === role.id)
.length > 0
}
> >
<svg <svg
width="16" width="16"

View File

@@ -1,39 +1,55 @@
let showSessionExpiredAlert = false; class ApiState {
let showLogoutAlert = false; showSessionExpiredAlert = false;
let getTokenFn: (() => string | null) | null = null; showLogoutAlert = false;
let refreshFn: (() => Promise<boolean>) | null = null; getTokenFn: (() => string | null) | null = null;
let refreshPromise: Promise<boolean> | 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 => { export const shouldShowSessionExpiredAlert = (): boolean => {
if (showSessionExpiredAlert) { if (state.showSessionExpiredAlert) {
showSessionExpiredAlert = false; state.showSessionExpiredAlert = false;
return true; return true;
} }
return false; return false;
}; };
export const setSessionExpired = (): void => { export const setSessionExpired = (): void => {
showSessionExpiredAlert = true; state.showSessionExpiredAlert = true;
}; };
export const shouldShowLogoutAlert = (): boolean => { export const shouldShowLogoutAlert = (): boolean => {
if (showLogoutAlert) { if (state.showLogoutAlert) {
showLogoutAlert = false; state.showLogoutAlert = false;
return true; return true;
} }
return false; return false;
}; };
export const setLogoutAlert = (): void => { export const setLogoutAlert = (): void => {
showLogoutAlert = true; state.showLogoutAlert = true;
}; };
export const setTokenGetter = (fn: () => string | null): void => { export const setTokenGetter = (fn: () => string | null): void => {
getTokenFn = fn; state.getTokenFn = fn;
}; };
export const setRefreshFn = (fn: () => Promise<boolean>): void => { export const setRefreshFn = (fn: () => Promise<boolean>): void => {
refreshFn = fn; state.refreshFn = fn;
}; };
export const apiFetch = async ( export const apiFetch = async (
@@ -42,7 +58,7 @@ export const apiFetch = async (
): Promise<Response> => { ): Promise<Response> => {
let token: string | null = null; let token: string | null = null;
try { try {
token = getTokenFn ? getTokenFn() : null; token = state.getTokenFn ? state.getTokenFn() : null;
} catch { } catch {
// token retrieval failed // token retrieval failed
} }
@@ -69,21 +85,22 @@ export const apiFetch = async (
credentials: "include", credentials: "include",
}); });
if (response.status === 401 && refreshFn) { if (response.status === 401 && state.refreshFn) {
try { try {
if (!refreshPromise) { if (!state.refreshPromise) {
refreshPromise = refreshFn().finally(() => { state.refreshPromise = state.refreshFn().finally(() => {
refreshPromise = null; state.refreshPromise = null;
}); });
} }
const refreshed = await refreshPromise; const refreshed = await state.refreshPromise;
if (refreshed) { if (refreshed) {
token = getTokenFn ? getTokenFn() : null; token = state.getTokenFn ? state.getTokenFn() : null;
if (token) { if (token) {
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const { signal, ...retryOptions } = options;
response = await fetch(url, { response = await fetch(url, {
...options, ...retryOptions,
headers, headers,
credentials: "include", credentials: "include",
}); });
@@ -100,7 +117,7 @@ export const apiFetch = async (
export const getAccessToken = (): string | null => { export const getAccessToken = (): string | null => {
try { try {
return getTokenFn ? getTokenFn() : null; return state.getTokenFn ? state.getTokenFn() : null;
} catch { } catch {
return null; return null;
} }

View File

@@ -56,7 +56,14 @@ export const config = {
path: process.env.NAS_PATH || "Z:/02_PROJEKTY", path: process.env.NAS_PATH || "Z:/02_PROJEKTY",
financialsPath: process.env.NAS_FINANCIALS_PATH || "", financialsPath: process.env.NAS_FINANCIALS_PATH || "",
offersPath: process.env.NAS_OFFERS_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: { email: {

View File

@@ -100,6 +100,9 @@ export default async function attendanceRoutes(
// --- action=balances: leave balance overview for all users --- // --- action=balances: leave balance overview for all users ---
if (action === "balances") { 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 yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getBalances(yr); const data = await attendanceService.getBalances(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
@@ -107,6 +110,9 @@ export default async function attendanceRoutes(
// --- action=workfund: monthly work fund overview --- // --- action=workfund: monthly work fund overview ---
if (action === "workfund") { 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 yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getWorkfund(yr); const data = await attendanceService.getWorkfund(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
@@ -114,6 +120,9 @@ export default async function attendanceRoutes(
// --- action=project_report: monthly project hours --- // --- action=project_report: monthly project hours ---
if (action === "project_report") { 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 yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getProjectReport(yr); const data = await attendanceService.getProjectReport(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
@@ -185,6 +194,10 @@ export default async function attendanceRoutes(
if (!id) return error(reply, "Missing id", 400); if (!id) return error(reply, "Missing id", 400);
const record = await attendanceService.getLocationRecord(id); const record = await attendanceService.getLocationRecord(id);
if (!record) return error(reply, "Záznam nenalezen", 404); 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 }); 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); if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400);
const leaveBody = leaveParsed.data; 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( const result = await attendanceService.createLeave(
{ {
user_id: leaveBody.user_id, user_id: leaveBody.user_id,
@@ -342,6 +363,14 @@ export default async function attendanceRoutes(
if ("error" in stdParsed) return error(reply, stdParsed.error, 400); if ("error" in stdParsed) return error(reply, stdParsed.error, 400);
const body = stdParsed.data; 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( const result = await attendanceService.createAttendance(
{ {
user_id: body.user_id, user_id: body.user_id,
@@ -364,6 +393,8 @@ export default async function attendanceRoutes(
}, },
authData.userId, authData.userId,
); );
if ("error" in result)
return error(reply, result.error!, result.status ?? 400);
await logAudit({ await logAudit({
request, request,

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, paginated, error } from "../../utils/response"; import { success, paginated, error } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; 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) // days === 0 means "delete all" (from frontend "Vše" option)
if (days === 0 || body.action === "all") { if (days === 0 || body.action === "all") {
const result = await prisma.audit_logs.deleteMany({}); 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ů`); 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({ const result = await prisma.audit_logs.deleteMany({
where: { created_at: { lt: cutoff } }, 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( return success(
reply, reply,
null, null,

View File

@@ -92,7 +92,15 @@ export default async function authRoutes(
// POST /api/admin/login/totp // POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>( fastify.post<{ Body: TotpVerifyRequest }>(
"/login/totp", "/login/totp",
{ bodyLimit: 10240 }, {
config: {
rateLimit: {
max: 20,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => { async (request, reply) => {
const parsed = parseBody(TotpVerifySchema, request.body); const parsed = parseBody(TotpVerifySchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -106,20 +114,42 @@ export default async function authRoutes(
.update(login_token) .update(login_token)
.digest("hex"); .digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({ const totpResult = await prisma.$transaction(async (tx) => {
where: { token_hash: tokenHash }, 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()) { if ("error" in totpResult) {
return error(reply, "Neplatný nebo expirovaný login token", 401); return error(reply, totpResult.error!, totpResult.status!);
} }
const user = await prisma.users.findUnique({ const user = totpResult.user;
where: { id: storedToken.user_id }, if (!user.totp_secret) {
include: { roles: true },
});
if (!user || !user.totp_secret) {
return error(reply, "Uživatel nenalezen", 401); return error(reply, "Uživatel nenalezen", 401);
} }
@@ -128,8 +158,6 @@ export default async function authRoutes(
return error(reply, "Neplatný TOTP kód", 401); 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) // Reset failed attempts and update last login (TOTP verified = successful login)
await prisma.users.update({ await prisma.users.update({
where: { id: user.id }, where: { id: user.id },
@@ -186,31 +214,43 @@ export default async function authRoutes(
); );
// POST /api/admin/refresh // POST /api/admin/refresh
fastify.post("/refresh", { bodyLimit: 10240 }, async (request, reply) => { fastify.post(
const refreshTokenRaw = request.cookies.refresh_token; "/refresh",
if (!refreshTokenRaw) { {
return error(reply, "Refresh token chybí", 401); 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") { if (result.type === "error") {
reply.clearCookie("refresh_token", { reply.clearCookie("refresh_token", {
path: "/api/admin", path: "/api/admin",
httpOnly: true, httpOnly: true,
secure: config.isProduction, secure: config.isProduction,
sameSite: "strict", 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 // POST /api/admin/logout
fastify.post("/logout", async (request, reply) => { fastify.post("/logout", async (request, reply) => {

View File

@@ -74,6 +74,13 @@ export default async function companySettingsRoutes(
let mime = "image/png"; let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; 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] === 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 return reply
.type(mime) .type(mime)
@@ -324,15 +331,12 @@ export default async function companySettingsRoutes(
nas: { nas: {
projects: { projects: {
configured: projectNas.isConfigured(), configured: projectNas.isConfigured(),
path: config.nas.path || "—",
}, },
financials: { financials: {
configured: nasFinancialsManager.isConfigured(), configured: nasFinancialsManager.isConfigured(),
path: config.nas.financialsPath || "—",
}, },
offers: { offers: {
configured: nasOffersManager.isConfigured(), configured: nasOffersManager.isConfigured(),
path: config.nas.offersPath || "—",
}, },
}, },
}); });

View File

@@ -56,54 +56,58 @@ function decodeCustomFields(raw: string | null): {
export default async function customersRoutes( export default async function customersRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
): Promise<void> { ): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => { fastify.get(
const { page, limit, skip, sort, order, search } = parsePagination( "/",
request.query as Record<string, unknown>, { preHandler: requirePermission("customers.view") },
); async (request, reply) => {
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name"; const { page, limit, skip, sort, order, search } = parsePagination(
request.query as Record<string, unknown>,
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 { const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
...c,
custom_fields,
customer_field_order,
quotation_count: c._count?.quotations ?? 0,
};
});
return reply.send({ const where = search
success: true, ? {
data: enriched, OR: [
pagination: buildPaginationMeta(total, page, limit), { 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 } }>( fastify.get<{ Params: { id: string } }>(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requirePermission("customers.view") },
async (request, reply) => { async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;

View File

@@ -7,6 +7,12 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates"; import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date"; 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 ─────────────────────────────────────────────────────── */ /* ── 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(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
s = s.replace(/(&nbsp;)/g, " "); s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = ""; let prev = "";
while (prev !== s) { while (prev !== s) {
prev = s; prev = s;
@@ -78,7 +85,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null; let fieldOrder: string[] | null = null;
const raw = entity.custom_fields; const raw = entity.custom_fields;
if (raw) { 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 && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) { if ((parsed as Record<string, unknown>).fields) {
cfData = cfData =
@@ -250,185 +262,189 @@ export default async function invoicesPdfRoutes(
"/:id", "/:id",
{ preHandler: requirePermission("invoices.export") }, { preHandler: requirePermission("invoices.export") },
async (request, reply) => { 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 query = request.query as Record<string, string>;
const lang = query.lang === "en" ? "en" : "cs";
const t = translations[lang];
const invoice = await prisma.invoices.findUnique({ try {
where: { id }, const lang = query.lang === "en" ? "en" : "cs";
}); const t = translations[lang];
if (!invoice) { const invoice = await prisma.invoices.findUnique({
return reply where: { id },
.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( if (!invoice) {
String( return reply
orderRow.customer_order_number || orderRow.order_number || "", .status(404)
), .type("text/html")
); .send("<html><body><h1>Faktura nenalezena</h1></body></html>");
if (orderRow.created_at) { }
orderDate = formatDate(orderRow.created_at);
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 = ""; let logoImg = "";
if (settings?.logo_data) { if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer); const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png"; let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; 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] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp"; else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString("base64"); const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`; 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 totalVat = 0; const currency = invoice.currency || "CZK";
for (const data of Object.values(vatSummary)) { const applyVat = !!invoice.apply_vat;
totalVat += data.vat;
}
const totalToPay = subtotal + totalVat;
// QR code - SPAYD payment format const vatSummary: Record<string, { base: number; vat: number }> = {};
let qrSvg = ""; let subtotal = 0;
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
}
// VAT recapitulation (always in CZK — Czech tax requirement) for (const item of items) {
const isForeign = currency.toUpperCase() !== "CZK"; const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
const issueDateStr = invoice.issue_date subtotal += lineSubtotal;
? localDateStr(new Date(invoice.issue_date)) const rate = Number(item.vat_rate);
: undefined; const key = String(rate);
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0; if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
const vatRates = [21, 12, 0]; vatSummary[key].base += lineSubtotal;
const vatRecap: Array<{ if (applyVat) {
rate: number; vatSummary[key].vat +=
base: number; Math.round(((lineSubtotal * rate) / 100) * 100) / 100;
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); let totalVat = 0;
const cust = buildAddressLines(customer, false, t); for (const data of Object.values(vatSummary)) {
totalVat += data.vat;
}
const totalToPay = subtotal + totalVat;
const suppLinesHtml = supp.lines // QR code - SPAYD payment format
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`) let qrSvg = "";
.join(""); try {
const custLinesHtml = cust.lines const spaydParts = [
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`) "SPD*1.0",
.join(""); "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 // VAT recapitulation (always in CZK — Czech tax requirement)
let suppEmail = ""; const isForeign = currency.toUpperCase() !== "CZK";
if (settings?.custom_fields) { const issueDateStr = invoice.issue_date
const raw = settings.custom_fields; ? localDateStr(new Date(invoice.issue_date))
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; : undefined;
if (parsed && typeof parsed === "object") { const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
const fields = (parsed as Record<string, unknown>).fields; const vatRates = [21, 12, 0];
if (Array.isArray(fields)) { const vatRecap: Array<{
for (const f of fields) { rate: number;
if (f.name && f.name.toLowerCase() === "email" && f.value) { base: number;
suppEmail = String(f.value); 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 const itemsHtml = items
.map((item, i) => { .map((item, i) => {
const qty = Number(item.quantity); const qty = Number(item.quantity);
const unitPrice = Number(item.unit_price); const unitPrice = Number(item.unit_price);
const lineSubtotal = qty * unitPrice; const lineSubtotal = qty * unitPrice;
const vatRate = Number(item.vat_rate); const vatRate = Number(item.vat_rate);
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0; const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat; const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
return `<tr> return `<tr>
<td class="row-num">${i + 1}</td> <td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td> <td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</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">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td> <td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
const vatRecapHtml = vatRecap const vatRecapHtml = vatRecap
.map( .map(
(vr) => `<tr> (vr) => `<tr>
<td class="right">${formatNum(vr.base)}</td> <td class="right">${formatNum(vr.base)}</td>
<td class="center">${Math.floor(vr.rate)}%</td> <td class="center">${Math.floor(vr.rate)}%</td>
<td class="right">${formatNum(vr.vat)}</td> <td class="right">${formatNum(vr.vat)}</td>
<td class="right">${formatNum(vr.total)}</td> <td class="right">${formatNum(vr.total)}</td>
</tr>`, </tr>`,
) )
.join(""); .join("");
let vatDetailHtml = ""; let vatDetailHtml = "";
if (applyVat) { if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) { for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) { if (data.vat > 0) {
vatDetailHtml += ` vatDetailHtml += `
<div class="row"> <div class="row">
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span> <span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span> <span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
</div>`; </div>`;
}
} }
} }
}
const notesRaw = invoice.notes ?? ""; const notesRaw = invoice.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped const notesHtml = notesStripped
? ` ? `
<!-- Poznamky --> <!-- Poznamky -->
<div class="invoice-notes"> <div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div> <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> </div>
` `
: ""; : "";
// Quill indent CSS // Quill indent CSS
let indentCSS = ""; let indentCSS = "";
for (let n = 1; n <= 9; n++) { for (let n = 1; n <= 9; n++) {
const pad = n * 3; const pad = n * 3;
const liPad = n * 3 + 1.5; const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
} }
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}"> <html lang="${escapeHtml(lang)}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -1000,33 +1016,36 @@ ${indentCSS}
</body> </body>
</html>`; </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"; 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) { // Save PDF to NAS
await pdfPromise; 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.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>");
}
}, },
); );
} }

View File

@@ -191,10 +191,8 @@ export default async function invoicesRoutes(
if (!existing) return error(reply, "Faktura nenalezena", 404); if (!existing) return error(reply, "Faktura nenalezena", 404);
// Delete PDF from NAS // Delete PDF from NAS
if (existing.invoice_number && existing.issue_date) { if (existing.invoice_number) {
const d = new Date(existing.issue_date); await nasFinancialsManager.cleanIssuedInvoice(existing.invoice_number);
const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${existing.invoice_number}.pdf`;
nasFinancialsManager.deleteIssuedInvoice(relPath);
} }
await logAudit({ await logAudit({

View File

@@ -241,6 +241,19 @@ export default async function leaveRequestsRoutes(
const totalHours = totalBusinessDays * 8; 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) => { await prisma.$transaction(async (tx) => {
// 1. Create attendance records for each business day // 1. Create attendance records for each business day
if (attendanceCreates.length > 0) { if (attendanceCreates.length > 0) {
@@ -331,6 +344,7 @@ export default async function leaveRequestsRoutes(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requireAuth },
async (request, reply) => { async (request, reply) => {
const authData = request.authData!;
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const existing = await prisma.leave_requests.findUnique({ 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); 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({ await prisma.leave_requests.update({
where: { id }, where: { id },
data: { status: "cancelled" }, data: { status: "cancelled" },

View File

@@ -4,6 +4,12 @@ import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date"; import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager"; import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf"; 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 { function formatDate(date: Date | string | null | undefined): string {
if (!date) return ""; 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="#"'); s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
// Replace &nbsp; with regular space (outside of tags) // Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " "); s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
// Merge adjacent spans with same attributes // Merge adjacent spans with same attributes
let prev = ""; let prev = "";
while (prev !== s) { while (prev !== s) {
@@ -102,7 +109,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null; let fieldOrder: string[] | null = null;
const raw = entity.custom_fields; const raw = entity.custom_fields;
if (raw) { 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 && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) { if ((parsed as Record<string, unknown>).fields) {
cfData = cfData =
@@ -201,7 +213,8 @@ export default async function offersPdfRoutes(
"/:id", "/:id",
{ preHandler: requirePermission("offers.view") }, { preHandler: requirePermission("offers.view") },
async (request, reply) => { 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 query = request.query as Record<string, string>;
try { try {
@@ -349,7 +362,7 @@ export default async function offersPdfRoutes(
if (title) if (title)
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`; scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content) if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`; scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>"; scopeHtml += "</div>";
} }
scopeHtml += "</div>"; scopeHtml += "</div>";
@@ -761,28 +774,24 @@ ${indentCSS}
</body> </body>
</html>`; </html>`;
const saveMode = query.save === "1";
// Save PDF to NAS // Save PDF to NAS
if (nasOffersManager.isConfigured() && quotation.quotation_number) { if (
saveMode &&
nasOffersManager.isConfigured() &&
quotation.quotation_number
) {
const created = quotation.created_at const created = quotation.created_at
? new Date(quotation.created_at) ? new Date(quotation.created_at)
: new Date(); : new Date();
const saveMode = query.save === "1"; const pdfBuffer = await htmlToPdf(html);
const pdfPromise = htmlToPdf(html) nasOffersManager.saveOfferPdf(
.then((pdfBuffer) => { quotation.quotation_number!,
nasOffersManager.saveOfferPdf( created.getFullYear(),
quotation.quotation_number!, pdfBuffer,
created.getFullYear(), );
pdfBuffer, return reply.send({ success: true, message: "PDF uloženo" });
);
})
.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" });
}
} }
return reply.type("text/html").send(html); return reply.type("text/html").send(html);

View File

@@ -3,6 +3,31 @@ import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date"; import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf"; 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 ─────────────────────────────────────────────────────── */ /* ── 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(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
s = s.replace(/(&nbsp;)/g, " "); s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = ""; let prev = "";
while (prev !== s) { while (prev !== s) {
prev = s; prev = s;
@@ -74,7 +100,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null; let fieldOrder: string[] | null = null;
const raw = entity.custom_fields; const raw = entity.custom_fields;
if (raw) { 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 && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) { if ((parsed as Record<string, unknown>).fields) {
cfData = cfData =
@@ -213,129 +244,133 @@ export default async function ordersPdfRoutes(
"/:id/confirmation", "/:id/confirmation",
{ preHandler: requirePermission("orders.view") }, { preHandler: requirePermission("orders.view") },
async (request, reply) => { async (request, reply) => {
const id = parseInt(request.params.id, 10); const id = parseId(request.params.id, reply);
const body = request.body || {}; if (id === null) return;
const lang = body.lang === "en" ? "en" : "cs"; const parsed = parseBody(OrderPdfBodySchema, request.body || {});
const t = translations[lang]; if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const order = await prisma.orders.findUnique({ try {
where: { id }, const lang = body.lang === "en" ? "en" : "cs";
include: { const t = translations[lang];
customers: true,
order_items: { orderBy: { position: "asc" } },
},
});
if (!order) { const order = await prisma.orders.findUnique({
return reply where: { id },
.status(404) include: {
.type("text/html") customers: true,
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>"); order_items: { orderBy: { position: "asc" } },
} },
});
const settings = (await prisma.company_settings.findFirst()) as Record< if (!order) {
string, return reply
unknown .status(404)
> | null; .type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
let logoImg = ""; const settings = (await prisma.company_settings.findFirst()) as Record<
if (settings?.logo_data) { string,
const buf = Buffer.from(settings.logo_data as Buffer); unknown
let mime = "image/png"; > | null;
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 = order.currency || "CZK"; let logoImg = "";
const applyVat = if (settings?.logo_data) {
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat; const buf = Buffer.from(settings.logo_data as Buffer);
const orderVatRate = Number(order.vat_rate) || 21; 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 currency = order.currency || "CZK";
const customItemsRaw = body.items; const applyVat =
let items: Array<{ body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
description: string; const orderVatRate = Number(order.vat_rate) || 21;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}> = [];
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) { // Use custom items from body if provided, otherwise order items
items = customItemsRaw.map((it: Record<string, unknown>) => ({ const customItemsRaw = body.items;
description: String(it.description || ""), let items: Array<{
quantity: Number(it.quantity) || 0, description: string;
unit: String(it.unit || ""), quantity: number;
unit_price: Number(it.unit_price) || 0, unit: string;
is_included_in_total: unit_price: number;
it.is_included_in_total !== false && it.is_included_in_total !== 0, is_included_in_total: boolean;
vat_rate: Number(it.vat_rate) || orderVatRate, vat_rate: number;
})); }> = [];
} 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; if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
let totalVat = 0; items = customItemsRaw.map((it) => ({
const vatSummary: Record<string, { base: number; vat: number }> = {}; description: it.description,
for (const item of items) { quantity: it.quantity,
if (item.is_included_in_total) { unit: it.unit,
const lineTotal = item.quantity * item.unit_price; unit_price: it.unit_price,
subtotal += lineTotal; is_included_in_total: it.is_included_in_total !== false,
const rate = item.vat_rate; vat_rate: it.vat_rate,
const key = String(rate); }));
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; } else {
vatSummary[key].base += lineTotal; items = order.order_items.map((it) => ({
if (applyVat) { description: it.description || "",
const lineVat = (lineTotal * rate) / 100; quantity: Number(it.quantity) || 0,
vatSummary[key].vat += lineVat; unit: it.unit || "",
totalVat += lineVat; 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 const userName = request.authData
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim() ? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
: ""; : "";
const supp = buildAddressLines(settings, true, t); const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines( const cust = buildAddressLines(
(order.customers as Record<string, unknown>) || null, (order.customers as Record<string, unknown>) || null,
false, false,
t, t,
); );
const suppLinesHtml = supp.lines const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`) .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join(""); .join("");
const custLinesHtml = cust.lines const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`) .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join(""); .join("");
const orderNumber = escapeHtml(order.order_number || ""); const orderNumber = escapeHtml(order.order_number || "");
const poNumber = escapeHtml(order.customer_order_number || ""); const poNumber = escapeHtml(order.customer_order_number || "");
const orderDateStr = formatDate(order.created_at); const orderDateStr = formatDate(order.created_at);
const itemsHtml = items const itemsHtml = items
.map((item, i) => { .map((item, i) => {
const lineSubtotal = item.quantity * item.unit_price; const lineSubtotal = item.quantity * item.unit_price;
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0; const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat; const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = const qtyDecimals =
Math.floor(item.quantity) === item.quantity ? 0 : 2; Math.floor(item.quantity) === item.quantity ? 0 : 2;
return `<tr> return `<tr>
<td class="row-num">${i + 1}</td> <td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td> <td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</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">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td> <td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer"; const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
let vatDetailHtml = ""; let vatDetailHtml = "";
if (applyVat) { if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) { for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) { if (data.vat > 0) {
vatDetailHtml += ` vatDetailHtml += `
<div class="row"> <div class="row">
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span> <span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span> <span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
</div>`; </div>`;
}
} }
} }
}
const notesRaw = order.notes ?? ""; const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped const notesHtml = notesStripped
? ` ? `
<div class="invoice-notes"> <div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div> <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> </div>
` `
: ""; : "";
// Quill indent CSS // Quill indent CSS
let indentCSS = ""; let indentCSS = "";
for (let n = 1; n <= 9; n++) { for (let n = 1; n <= 9; n++) {
const pad = n * 3; const pad = n * 3;
const liPad = n * 3 + 1.5; const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
} }
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}"> <html lang="${escapeHtml(lang)}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -846,13 +881,20 @@ ${indentCSS}
</body> </body>
</html>`; </html>`;
const pdfBuffer = await htmlToPdf(html); const pdfBuffer = await htmlToPdf(html);
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`; const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
return reply return reply
.type("application/pdf") .type("application/pdf")
.header("Content-Disposition", `attachment; filename="${filename}"`) .header("Content-Disposition", `attachment; filename="${filename}"`)
.send(pdfBuffer); .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>");
}
}, },
); );
} }

View File

@@ -88,12 +88,10 @@ export default async function ordersRoutes(
const attachment = await getOrderAttachment(id); const attachment = await getOrderAttachment(id);
if (!attachment) return error(reply, "Příloha nenalezena", 404); if (!attachment) return error(reply, "Příloha nenalezena", 404);
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
return reply return reply
.type("application/pdf") .type("application/pdf")
.header( .header("Content-Disposition", `inline; filename="${safeFilename}"`)
"Content-Disposition",
`inline; filename="${attachment.filename}"`,
)
.send(attachment.data); .send(attachment.data);
}, },
); );
@@ -209,6 +207,7 @@ export default async function ordersRoutes(
const body = manualParsed.data; const body = manualParsed.data;
const result = await createOrder(body); const result = await createOrder(body);
if ("error" in result) return error(reply, result.error!, result.status!);
await logAudit({ await logAudit({
request, request,

View File

@@ -75,6 +75,14 @@ export default async function profileRoutes(
} }
await prisma.users.update({ where: { id: userId }, data }); 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"); return success(reply, null, 200, "Profil aktualizován");
}); });
} }

View File

@@ -1,6 +1,7 @@
import fs from "fs"; import fs from "fs";
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart"; import multipart from "@fastify/multipart";
import { z } from "zod";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { config } from "../../config/env"; import { config } from "../../config/env";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
@@ -8,6 +9,28 @@ import { logAudit } from "../../services/audit";
import { success, error } from "../../utils/response"; import { success, error } from "../../utils/response";
import { NasFileManager } from "../../services/nas-file-manager"; 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( export default async function projectFilesRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
): Promise<void> { ): Promise<void> {
@@ -30,8 +53,10 @@ export default async function projectFilesRoutes(
"/", "/",
{ preHandler: requirePermission("projects.view") }, { preHandler: requirePermission("projects.view") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); 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); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); 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); return error(reply, "Souborový systém není nakonfigurován", 500);
} }
const subPath = query.path || ""; if (parsedQuery.data.action === "download") {
if (query.action === "download") {
if (!subPath) return error(reply, "Cesta k souboru je povinná"); if (!subPath) return error(reply, "Cesta k souboru je povinná");
if (!project.project_number) if (!project.project_number)
return error(reply, "Projekt nemá číslo projektu"); return error(reply, "Projekt nemá číslo projektu");
@@ -81,8 +104,9 @@ export default async function projectFilesRoutes(
"/", "/",
{ preHandler: requirePermission("projects.files") }, { preHandler: requirePermission("projects.files") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) if (!project.project_number)
@@ -105,7 +129,11 @@ export default async function projectFilesRoutes(
fm.createProjectFolder(project.project_number, project.name || ""); 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); if (err !== null) return error(reply, err);
await logAudit({ await logAudit({
@@ -130,8 +158,9 @@ export default async function projectFilesRoutes(
bodyLimit: config.nas.maxUploadSize, bodyLimit: config.nas.maxUploadSize,
}, },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) if (!project.project_number)
@@ -149,14 +178,13 @@ export default async function projectFilesRoutes(
const file = await request.file(); const file = await request.file();
if (!file) return error(reply, "Nebyl nahrán žádný soubor"); if (!file) return error(reply, "Nebyl nahrán žádný soubor");
const subPath = query.path || ""; const subPath = parsedQuery.data.path || "";
const fileBuffer = await file.toBuffer();
const fileName = file.filename; const fileName = file.filename;
const err = await fm.uploadFile( const err = await fm.uploadFile(
project.project_number, project.project_number,
subPath, subPath,
fileBuffer, file.file,
fileName, fileName,
); );
if (err !== null) return error(reply, err); if (err !== null) return error(reply, err);
@@ -180,8 +208,9 @@ export default async function projectFilesRoutes(
"/", "/",
{ preHandler: requirePermission("projects.files") }, { preHandler: requirePermission("projects.files") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) if (!project.project_number)
@@ -198,7 +227,7 @@ export default async function projectFilesRoutes(
if (!fromPath || !toPath) if (!fromPath || !toPath)
return error(reply, "Zdrojová i cílová cesta jsou povinné"); 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); if (err !== null) return error(reply, err);
await logAudit({ await logAudit({
@@ -221,8 +250,9 @@ export default async function projectFilesRoutes(
"/", "/",
{ preHandler: requirePermission("projects.files") }, { preHandler: requirePermission("projects.files") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) if (!project.project_number)
@@ -232,7 +262,7 @@ export default async function projectFilesRoutes(
return error(reply, "Souborový systém není nakonfigurován", 500); 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á"); if (!filePath) return error(reply, "Cesta k souboru je povinná");
const err = await fm.deleteItem(project.project_number, filePath); const err = await fm.deleteItem(project.project_number, filePath);

View File

@@ -69,6 +69,9 @@ export default async function projectsRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const project = await createProject(parsed.data); const project = await createProject(parsed.data);
if ("error" in project) {
return error(reply, project.error, (project as any).status ?? 400);
}
await logAudit({ await logAudit({
request, request,

View File

@@ -245,6 +245,8 @@ export default async function quotationsRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const quotation = await createOffer(parsed.data); const quotation = await createOffer(parsed.data);
if ("error" in quotation)
return error(reply, quotation.error!, quotation.status!);
await logAudit({ await logAudit({
request, request,
@@ -312,9 +314,13 @@ export default async function quotationsRoutes(
// Delete PDF from NAS // Delete PDF from NAS
if (existing.quotation_number && existing.created_at) { if (existing.quotation_number && existing.created_at) {
const yr = new Date(existing.created_at).getFullYear(); const yr = new Date(existing.created_at).getFullYear();
nasOffersManager.deleteOfferPdf( try {
nasOffersManager.buildRelativePath(existing.quotation_number, yr), await nasOffersManager.deleteOfferPdf(
); nasOffersManager.buildRelativePath(existing.quotation_number, yr),
);
} catch {
// Non-fatal: NAS delete may fail if file does not exist
}
} }
await logAudit({ await logAudit({

View File

@@ -111,13 +111,15 @@ export default async function rolesRoutes(
}); });
if (Array.isArray(body.permission_ids)) { if (Array.isArray(body.permission_ids)) {
await prisma.role_permissions.deleteMany({ where: { role_id: id } }); await prisma.$transaction([
await prisma.role_permissions.createMany({ prisma.role_permissions.deleteMany({ where: { role_id: id } }),
data: (body.permission_ids as number[]).map((pid) => ({ prisma.role_permissions.createMany({
role_id: id, data: (body.permission_ids as number[]).map((pid) => ({
permission_id: pid, role_id: id,
})), permission_id: pid,
}); })),
}),
]);
} }
await logAudit({ await logAudit({

View File

@@ -54,6 +54,39 @@ export default async function totpRoutes(
return error(reply, "Secret a kód jsou povinné", 400); 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({ const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(String(secret)), secret: OTPAuthLib.Secret.fromBase32(String(secret)),
algorithm: "SHA1", algorithm: "SHA1",
@@ -185,10 +218,26 @@ export default async function totpRoutes(
const required = const required =
body.required === true || body.required === 1 || body.required === "1"; 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({ await prisma.company_settings.updateMany({
data: { require_2fa: required }, 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 const message = required
? "2FA je nyní povinné pro všechny uživatele" ? "2FA je nyní povinné pro všechny uživatele"
: "2FA již není povinné"; : "2FA již není povinné";
@@ -200,7 +249,15 @@ export default async function totpRoutes(
// POST - verify backup code (pre-auth, no requireAuth) // POST - verify backup code (pre-auth, no requireAuth)
fastify.post( fastify.post(
"/backup-verify", "/backup-verify",
{ bodyLimit: 10240 }, {
config: {
rateLimit: {
max: 5,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => { async (request, reply) => {
const parsed = parseBody(TotpBackupSchema, request.body); const parsed = parseBody(TotpBackupSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -211,52 +268,95 @@ export default async function totpRoutes(
.update(login_token) .update(login_token)
.digest("hex"); .digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({ const settings = await getSystemSettings();
where: { token_hash: tokenHash },
});
if (!storedToken || new Date(storedToken.expires_at) < new Date()) { const txResult = await prisma.$transaction(async (tx) => {
return error(reply, "Neplatný nebo expirovaný login token", 401); 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({ if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
where: { id: storedToken.user_id }, return { error: "Neplatný nebo expirovaný login token", status: 401 };
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 (matchIndex === -1) { const user = await tx.users.findUnique({
return error(reply, "Neplatný záložní kód", 401); where: { id: storedToken.user_id },
} include: { roles: true },
});
backupCodes.splice(matchIndex, 1); if (!user || !user.totp_backup_codes) {
await prisma.users.update({ return { error: "Uživatel nenalezen", status: 401 };
where: { id: user.id }, }
data: {
totp_backup_codes: JSON.stringify(backupCodes), if (!user.is_active) {
failed_login_attempts: 0, return { error: "Účet je deaktivován", status: 401 };
locked_until: null, }
last_login: new Date(),
}, 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) // Create tokens (same as /login/totp flow)
const { loadAuthData } = await import("../../services/auth"); const { loadAuthData } = await import("../../services/auth");

View File

@@ -206,6 +206,10 @@ export default async function tripsRoutes(
const body = parsed.data; const body = parsed.data;
const authData = request.authData!; 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({ const trip = await prisma.trips.create({
data: { data: {
vehicle_id: Number(body.vehicle_id), vehicle_id: Number(body.vehicle_id),
@@ -247,6 +251,18 @@ export default async function tripsRoutes(
const body = parsed.data; const body = parsed.data;
const authData = request.authData!; 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 } }); const existing = await prisma.trips.findUnique({ where: { id } });
if (!existing) return error(reply, "Jízda nenalezena", 404); if (!existing) return error(reply, "Jízda nenalezena", 404);

View File

@@ -1,4 +1,5 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response"; 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); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const result = await createUser({ const result = await createUser(
username: body.username, {
email: body.email, username: body.username,
password: body.password, email: body.email,
first_name: body.first_name, password: body.password,
last_name: body.last_name, first_name: body.first_name,
role_id: body.role_id, last_name: body.last_name,
is_active: body.is_active, 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!); 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) ? Number(parsed.data.role_id)
: (parsed.data.role_id as number | null | undefined), : (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 ("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({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,

View File

@@ -6,12 +6,14 @@ const QuotationItemSchema = z.object({
quantity: z quantity: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 1) .transform((v) => Number(v) || 1)
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(1), .default(1),
unit: z.string().nullish(), unit: z.string().nullish(),
unit_price: z unit_price: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 0) .transform((v) => Number(v) || 0)
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(0), .default(0),
is_included_in_total: z is_included_in_total: z
@@ -21,6 +23,7 @@ const QuotationItemSchema = z.object({
position: z position: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
}); });
@@ -31,6 +34,7 @@ const ScopeSectionSchema = z.object({
position: z position: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
}); });
@@ -40,6 +44,7 @@ export const CreateQuotationSchema = z.object({
customer_id: z customer_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
.nullish(), .nullish(),
valid_until: z.string().nullish(), valid_until: z.string().nullish(),
currency: z.string().optional().default("CZK"), currency: z.string().optional().default("CZK"),
@@ -47,6 +52,7 @@ export const CreateQuotationSchema = z.object({
vat_rate: z vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(21.0), .default(21.0),
apply_vat: z apply_vat: z
@@ -56,9 +62,13 @@ export const CreateQuotationSchema = z.object({
exchange_rate: z exchange_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(1.0), .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_title: z.string().nullish(),
scope_description: z.string().nullish(), scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(), items: z.array(QuotationItemSchema).optional(),
@@ -70,6 +80,7 @@ export const UpdateQuotationSchema = z.object({
customer_id: z customer_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
valid_until: z.union([z.string(), z.null()]).optional(), valid_until: z.union([z.string(), z.null()]).optional(),
currency: z.string().optional(), currency: z.string().optional(),
@@ -77,6 +88,7 @@ export const UpdateQuotationSchema = z.object({
vat_rate: z vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
apply_vat: z apply_vat: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
@@ -84,8 +96,11 @@ export const UpdateQuotationSchema = z.object({
exchange_rate: z exchange_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .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(), .optional(),
status: z.string().optional(),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
scope_description: z.string().nullish(), scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(), items: z.array(QuotationItemSchema).optional(),

View File

@@ -6,12 +6,14 @@ const OrderItemSchema = z.object({
quantity: z quantity: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 1) .transform((v) => Number(v) || 1)
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(1), .default(1),
unit: z.string().nullish(), unit: z.string().nullish(),
unit_price: z unit_price: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 0) .transform((v) => Number(v) || 0)
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(0), .default(0),
is_included_in_total: z is_included_in_total: z
@@ -21,6 +23,7 @@ const OrderItemSchema = z.object({
position: z position: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
}); });
@@ -31,11 +34,15 @@ const OrderSectionSchema = z.object({
position: z position: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
}); });
export const CreateOrderFromQuotationSchema = z.object({ 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(""), customerOrderNumber: z.string().optional().default(""),
}); });
@@ -45,17 +52,23 @@ export const CreateOrderSchema = z.object({
quotation_id: z quotation_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
.nullish(), .nullish(),
customer_id: z customer_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
.nullish(), .nullish(),
status: z.string().optional().default("prijata"), status: z
.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"])
.optional()
.default("prijata"),
currency: z.string().optional().default("CZK"), currency: z.string().optional().default("CZK"),
language: z.string().optional().default("cs"), language: z.string().optional().default("cs"),
vat_rate: z vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(21.0), .default(21.0),
apply_vat: z apply_vat: z
@@ -65,6 +78,7 @@ export const CreateOrderSchema = z.object({
exchange_rate: z exchange_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(1.0), .default(1.0),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
@@ -76,7 +90,7 @@ export const CreateOrderSchema = z.object({
export const UpdateOrderSchema = z.object({ export const UpdateOrderSchema = z.object({
customer_order_number: z.string().nullish(), customer_order_number: z.string().nullish(),
status: z.string().optional(), status: z.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]).optional(),
currency: z.string().optional(), currency: z.string().optional(),
language: z.string().optional(), language: z.string().optional(),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
@@ -86,6 +100,7 @@ export const UpdateOrderSchema = z.object({
vat_rate: z vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
apply_vat: z apply_vat: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())

View File

@@ -254,10 +254,7 @@ export async function getStatus(userId: number) {
} }
const worked = Math.round(workedHours * 100) / 100; const worked = Math.round(workedHours * 100) / 100;
const holidayDays = monthRecords.filter( const adjustedFund = Math.max(0, fund);
(r) => (r.leave_type as string) === "holiday",
).length;
const adjustedFund = Math.max(0, (workingDays - holidayDays) * 8);
const leaveHours = vacationHours + sickHours; const leaveHours = vacationHours + sickHours;
const covered = worked + leaveHours; const covered = worked + leaveHours;
const remaining = Math.max(0, adjustedFund - covered); const remaining = Math.max(0, adjustedFund - covered);
@@ -266,7 +263,7 @@ export async function getStatus(userId: number) {
const monthlyFund = { const monthlyFund = {
month_name: `${MONTH_NAMES[m]} ${y}`, month_name: `${MONTH_NAMES[m]} ${y}`,
fund: adjustedFund, fund: adjustedFund,
business_days: workingDays - holidayDays, business_days: workingDays,
worked, worked,
covered, covered,
remaining, remaining,
@@ -390,7 +387,11 @@ export async function updateAddress(
punchAction: string, punchAction: string,
) { ) {
const latest = await prisma.attendance.findFirst({ 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" }, orderBy: { created_at: "desc" },
}); });
if (!latest) return { error: "Nenalezen záznam" }; if (!latest) return { error: "Nenalezen záznam" };
@@ -574,7 +575,6 @@ export async function getWorkfund(year: number) {
let worked = 0; let worked = 0;
let vacationHours = 0; let vacationHours = 0;
let sickHours = 0; let sickHours = 0;
let holidayDays = 0;
for (const rec of recs) { for (const rec of recs) {
const lt = (rec.leave_type as string) || "work"; const lt = (rec.leave_type as string) || "work";
@@ -591,8 +591,6 @@ export async function getWorkfund(year: number) {
vacationHours += Number(rec.leave_hours) || 8; vacationHours += Number(rec.leave_hours) || 8;
} else if (lt === "sick") { } else if (lt === "sick") {
sickHours += Number(rec.leave_hours) || 8; sickHours += Number(rec.leave_hours) || 8;
} else if (lt === "holiday") {
holidayDays++;
} }
} }
@@ -1224,6 +1222,12 @@ export async function createLeave(data: LeaveData, authUserId: number) {
0, 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({ await prisma.attendance.create({
data: { data: {
user_id: userId, user_id: userId,
@@ -1438,10 +1442,36 @@ export async function createAttendance(
data: CreateAttendanceData, data: CreateAttendanceData,
authUserId: number, 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({ const record = await prisma.attendance.create({
data: { data: {
user_id: data.user_id ?? authUserId, user_id: userId,
shift_date: new Date(data.shift_date), shift_date: shiftDate,
arrival_time: data.arrival_time ? new Date(data.arrival_time) : null, arrival_time: data.arrival_time ? new Date(data.arrival_time) : null,
arrival_lat: data.arrival_lat ?? null, arrival_lat: data.arrival_lat ?? null,
arrival_lng: data.arrival_lng ?? null, arrival_lng: data.arrival_lng ?? null,

View File

@@ -109,32 +109,42 @@ export async function login(
} }
if (!user.is_active) { 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()) { if (user.locked_until && new Date(user.locked_until) > new Date()) {
request.log.warn(`Login failed for locked user: ${username}`);
return { return {
type: "error", type: "error",
message: "Účet je dočasně uzamčen. Zkuste to později.", message: "Neplatné přihlašovací údaje",
status: 429, status: 401,
}; };
} }
const passwordValid = await bcrypt.compare(password, user.password_hash); const passwordValid = await bcrypt.compare(password, user.password_hash);
if (!passwordValid) { if (!passwordValid) {
const settings = await getSystemSettings(); const settings = await getSystemSettings();
const attempts = (user.failed_login_attempts ?? 0) + 1; await prisma.users.update({
const updateData: Record<string, unknown> = { where: { id: user.id },
failed_login_attempts: attempts, data: { failed_login_attempts: { increment: 1 } },
}; });
if (attempts >= settings.max_login_attempts) { if ((user.failed_login_attempts ?? 0) + 1 >= settings.max_login_attempts) {
updateData.locked_until = new Date( await prisma.users.update({
Date.now() + settings.lockout_minutes * 60_000, 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 { return {
type: "error", type: "error",
message: "Neplatné přihlašovací údaje", 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) { if (user.totp_enabled) {
const loginToken = crypto.randomBytes(32).toString("hex"); const loginToken = crypto.randomBytes(32).toString("hex");
const tokenHash = hashToken(loginToken); const tokenHash = hashToken(loginToken);
@@ -222,60 +243,69 @@ export async function refreshAccessToken(
> { > {
const tokenHash = hashToken(refreshTokenRaw); const tokenHash = hashToken(refreshTokenRaw);
const storedToken = await prisma.refresh_tokens.findUnique({ return prisma.$transaction(async (tx) => {
where: { token_hash: tokenHash }, 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 ( if (
!storedToken || !storedToken ||
storedToken.replaced_at || storedToken.replaced_at ||
new Date(storedToken.expires_at) < new Date() new Date(storedToken.expires_at) < new Date()
) { ) {
return { type: "error", message: "Neplatný refresh token", status: 401 }; return { type: "error", message: "Neplatný refresh token", status: 401 };
} }
const authData = await loadAuthData(storedToken.user_id); const authData = await loadAuthData(storedToken.user_id);
if (!authData) { if (!authData) {
return { type: "error", message: "Uživatel nenalezen", status: 401 }; return { type: "error", message: "Uživatel nenalezen", status: 401 };
} }
const newRefreshTokenRaw = generateRefreshToken(); const newRefreshTokenRaw = generateRefreshToken();
const newRefreshTokenHash = hashToken(newRefreshTokenRaw); const newRefreshTokenHash = hashToken(newRefreshTokenRaw);
const expiresIn = storedToken.remember_me const expiresIn = storedToken.remember_me
? config.jwt.refreshTokenRememberExpiry ? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry; : config.jwt.refreshTokenSessionExpiry;
await prisma.$transaction([ await tx.refresh_tokens.update({
prisma.refresh_tokens.update({
where: { id: storedToken.id }, where: { id: storedToken.id },
data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash }, data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash },
}), });
prisma.refresh_tokens.create({ await tx.refresh_tokens.create({
data: { data: {
user_id: storedToken.user_id, user_id: storedToken.user_id,
token_hash: newRefreshTokenHash, token_hash: newRefreshTokenHash,
expires_at: new Date(Date.now() + expiresIn * 1000), expires_at: new Date(Date.now() + expiresIn * 1000),
remember_me: storedToken.remember_me, remember_me: storedToken.remember_me ?? false,
ip_address: request.ip, ip_address: request.ip,
user_agent: request.headers["user-agent"] ?? null, user_agent: request.headers["user-agent"] ?? null,
}, },
}), });
]);
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
id: authData.userId, id: authData.userId,
username: authData.username, username: authData.username,
roleName: authData.roleName, 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> { export async function logout(refreshTokenRaw: string): Promise<void> {

View File

@@ -37,7 +37,7 @@ async function fetchRatesForDate(
} catch (err) { } catch (err) {
console.error("Failed to fetch CNB exchange rates:", err); console.error("Failed to fetch CNB exchange rates:", err);
if (rateCache["today"]) return rateCache["today"]; 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; if (currency === "CZK") return amount;
const rates = await fetchRatesForDate(date); const rates = await fetchRatesForDate(date);
const rate = rates[currency]; 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; return Math.round(amount * rate * 100) / 100;
} }
@@ -61,5 +61,7 @@ export async function getRate(
): Promise<number> { ): Promise<number> {
if (currency === "CZK") return 1; if (currency === "CZK") return 1;
const rates = await fetchRatesForDate(date); 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;
} }

View File

@@ -48,11 +48,15 @@ export async function checkInvoiceAlerts(): Promise<void> {
const createdInvoices = await prisma.invoices.findMany({ const createdInvoices = await prisma.invoices.findMany({
where: { where: {
status: { in: ["issued", "overdue"] }, 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 } }, 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({ const receivedInvoices = await prisma.received_invoices.findMany({
where: { where: {
status: "unpaid", 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,
}, },
}); });

View File

@@ -55,10 +55,9 @@ function computeInvoiceTotals(
const vatAmount = applyVat const vatAmount = applyVat
? items.reduce((s, i) => { ? items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0); const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
return ( const vat =
s + base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100);
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100) return s + Math.round(vat * 100) / 100;
);
}, 0) }, 0)
: 0; : 0;
return { return {
@@ -156,6 +155,31 @@ export {
previewInvoiceNumber as getNextInvoiceNumberPreview, previewInvoiceNumber as getNextInvoiceNumberPreview,
} from "./numbering.service"; } 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) { export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const now = new Date(); const now = new Date();
const year = queryYear || now.getFullYear(); 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 monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59); 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({ const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
include: { invoice_items: true }, 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 aggregateByCurrency = (
const sub = inv.invoice_items.reduce( invoices: Parameters<typeof invoiceTotalWithVat>[0][],
(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 map: Record<string, number> = {}; const map: Record<string, number> = {};
for (const inv of invoices) { for (const inv of invoices) {
const cur = inv.currency || "CZK"; 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; let total = 0;
for (const inv of invoices) { for (const inv of invoices) {
const amount = invoiceTotalWithVat(inv); const amount = invoiceTotalWithVat(inv);
@@ -208,14 +240,7 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
return Math.round(total * 100) / 100; 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 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> = {}; const vatMap: Record<string, number> = {};
for (const inv of monthInvoices) { for (const inv of monthInvoices) {
@@ -454,8 +479,8 @@ export async function deleteInvoice(id: number) {
await prisma.invoices.delete({ where: { id } }); await prisma.invoices.delete({ where: { id } });
const year = existing.created_at const year = existing.invoice_number
? new Date(existing.created_at).getFullYear() ? Number(existing.invoice_number.split("/")[1]) || new Date().getFullYear()
: new Date().getFullYear(); : new Date().getFullYear();
await releaseInvoiceNumber(year); await releaseInvoiceNumber(year);

View File

@@ -1,5 +1,6 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { pipeline } from "stream/promises";
import { config } from "../config/env"; import { config } from "../config/env";
import { localDateStr, localTimeStr } from "../utils/date"; import { localDateStr, localTimeStr } from "../utils/date";
@@ -294,21 +295,21 @@ export class NasFileManager {
public async uploadFile( public async uploadFile(
projectNumber: string, projectNumber: string,
subPath: string, subPath: string,
fileBuffer: Buffer, fileStream: NodeJS.ReadableStream,
fileName: string, fileName: string,
): Promise<string | null> { ): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath); const dirPath = this.resolveProjectPath(projectNumber, subPath);
if ( if (dirPath === null) {
dirPath === null ||
!fs.existsSync(dirPath) ||
!fs.statSync(dirPath).isDirectory()
) {
return "Cílová složka neexistuje"; return "Cílová složka neexistuje";
} }
if (fileBuffer.length > config.nas.maxUploadSize) { try {
const maxMb = Math.round(config.nas.maxUploadSize / 1048576); const stat = await fs.promises.stat(dirPath);
return `Soubor je příliš velký (max ${maxMb} MB)`; if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
} catch {
return "Cílová složka neexistuje";
} }
const originalName = path.basename(fileName); const originalName = path.basename(fileName);
@@ -322,9 +323,22 @@ export class NasFileManager {
return "Tento typ souboru není povolen"; return "Tento typ souboru není povolen";
} }
const tempPath = path.join(
require("os").tmpdir(),
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try { 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)) { if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě"; return "Obsah souboru neodpovídá jeho příponě";
} }
} catch { } catch {
@@ -333,19 +347,28 @@ export class NasFileManager {
let destPath = dirPath + "/" + safeName; let destPath = dirPath + "/" + safeName;
if (fs.existsSync(destPath)) { try {
await fs.promises.stat(destPath);
const base = path.basename(safeName, ext ? "." + ext : ""); const base = path.basename(safeName, ext ? "." + ext : "");
let counter = 1; let counter = 1;
do { do {
safeName = base + "_" + counter + (ext ? "." + ext : ""); safeName = base + "_" + counter + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName; destPath = dirPath + "/" + safeName;
counter++; counter++;
} while (fs.existsSync(destPath)); } while (
await fs.promises
.stat(destPath)
.then(() => true)
.catch(() => false)
);
} catch {
// destPath does not exist, continue
} }
try { try {
fs.writeFileSync(destPath, fileBuffer); await fs.promises.rename(tempPath, destPath);
} catch { } catch {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor"; return "Nepodařilo se uložit soubor";
} }
@@ -381,7 +404,12 @@ export class NasFileManager {
projectNumber: string, projectNumber: string,
filePath: string, filePath: string,
): Promise<string | null> { ): Promise<string | null> {
if (filePath === "" || filePath === "/") { if (
filePath === "" ||
filePath === "/" ||
filePath === "." ||
filePath === "./"
) {
return "Nelze smazat kořenovou složku projektu"; return "Nelze smazat kořenovou složku projektu";
} }
@@ -390,22 +418,19 @@ export class NasFileManager {
return "Neplatná cesta"; return "Neplatná cesta";
} }
if (!fs.existsSync(fullPath)) {
return "Soubor nebo složka neexistuje";
}
let isDir: boolean; let isDir: boolean;
try { try {
isDir = fs.lstatSync(fullPath).isDirectory(); const stat = await fs.promises.lstat(fullPath);
isDir = stat.isDirectory();
} catch { } catch {
return "Neplatná cesta"; return "Soubor nebo složka neexistuje";
} }
try { try {
if (isDir) { if (isDir) {
await fs.promises.rm(fullPath, { recursive: true, force: true }); await fs.promises.rm(fullPath, { recursive: true, force: true });
} else { } else {
fs.unlinkSync(fullPath); await fs.promises.unlink(fullPath);
} }
} catch { } catch {
return isDir return isDir
@@ -416,12 +441,17 @@ export class NasFileManager {
return null; return null;
} }
public moveItem( public async moveItem(
projectNumber: string, projectNumber: string,
fromPath: string, fromPath: string,
toPath: string, toPath: string,
): string | null { ): Promise<string | null> {
if (fromPath === "" || fromPath === "/") { if (
fromPath === "" ||
fromPath === "/" ||
fromPath === "." ||
fromPath === "./"
) {
return "Nelze přesunout kořenovou složku"; return "Nelze přesunout kořenovou složku";
} }
@@ -432,7 +462,9 @@ export class NasFileManager {
return "Neplatná cesta"; return "Neplatná cesta";
} }
if (!fs.existsSync(fullFrom)) { try {
await fs.promises.stat(fullFrom);
} catch {
return "Zdrojový soubor neexistuje"; return "Zdrojový soubor neexistuje";
} }
@@ -441,8 +473,13 @@ export class NasFileManager {
fullFrom.replace(/\\/g, "/").toLowerCase() === fullFrom.replace(/\\/g, "/").toLowerCase() ===
fullTo.replace(/\\/g, "/").toLowerCase(); fullTo.replace(/\\/g, "/").toLowerCase();
if (fs.existsSync(fullTo) && !sameFile) { if (!sameFile) {
return "Cílový soubor již existuje"; try {
await fs.promises.stat(fullTo);
return "Cílový soubor již existuje";
} catch {
// target does not exist, continue
}
} }
const targetName = path.basename(toPath); const targetName = path.basename(toPath);
@@ -451,7 +488,7 @@ export class NasFileManager {
} }
try { try {
fs.renameSync(fullFrom, fullTo); await fs.promises.rename(fullFrom, fullTo);
} catch (err: unknown) { } catch (err: unknown) {
if ( if (
err instanceof Error && err instanceof Error &&
@@ -466,17 +503,22 @@ export class NasFileManager {
return null; return null;
} }
public createFolder( public async createFolder(
projectNumber: string, projectNumber: string,
subPath: string, subPath: string,
folderName: string, folderName: string,
): string | null { ): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath); const dirPath = this.resolveProjectPath(projectNumber, subPath);
if ( if (dirPath === null) {
dirPath === null || return "Nadřazená složka neexistuje";
!fs.existsSync(dirPath) || }
!fs.statSync(dirPath).isDirectory()
) { try {
const stat = await fs.promises.stat(dirPath);
if (!stat.isDirectory()) {
return "Nadřazená složka neexistuje";
}
} catch {
return "Nadřazená složka neexistuje"; return "Nadřazená složka neexistuje";
} }
@@ -486,12 +528,15 @@ export class NasFileManager {
} }
const newPath = dirPath + "/" + safeName; const newPath = dirPath + "/" + safeName;
if (fs.existsSync(newPath)) { try {
await fs.promises.stat(newPath);
return "Složka s tímto názvem již existuje"; return "Složka s tímto názvem již existuje";
} catch {
// does not exist, continue
} }
try { try {
fs.mkdirSync(newPath, { mode: 0o775 }); await fs.promises.mkdir(newPath, { mode: 0o775 });
} catch { } catch {
return "Nepodařilo se vytvořit složku"; return "Nepodařilo se vytvořit složku";
} }
@@ -572,6 +617,11 @@ export class NasFileManager {
} }
const normalized = subPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); 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, "/"); const candidate = path.resolve(folderPath, normalized).replace(/\\/g, "/");
// Verify candidate is within project folder // Verify candidate is within project folder

View File

@@ -68,11 +68,11 @@ class NasFinancialsManager {
if (!dir) return null; if (!dir) return null;
const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf"; const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf";
const fullPath = path.join(dir, safeName); const fullPath = this.uniquePath(dir, safeName);
try { try {
fs.writeFileSync(fullPath, pdfBuffer); fs.writeFileSync(fullPath, pdfBuffer);
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${safeName}`; return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`;
} catch { } catch {
return null; return null;
} }
@@ -125,12 +125,12 @@ class NasFinancialsManager {
let safeName = this.sanitizeFilename(originalName); let safeName = this.sanitizeFilename(originalName);
if (!safeName) safeName = "file"; if (!safeName) safeName = "file";
const fullPath = path.join(dir, safeName); const fullPath = this.uniquePath(dir, safeName);
try { try {
fs.writeFileSync(fullPath, fileBuffer); fs.writeFileSync(fullPath, fileBuffer);
return { return {
filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`, filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`,
}; };
} catch (e) { } catch (e) {
return { return {

View File

@@ -126,7 +126,7 @@ async function releaseSequence(type: string, year: number) {
} }
/** Verify a shared number is not already used by an order or project. */ /** 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([ const [existingOrder, existingProject] = await Promise.all([
prisma.orders.findFirst({ where: { order_number: number } }), prisma.orders.findFirst({ where: { order_number: number } }),
prisma.projects.findFirst({ where: { project_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. */ /** 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({ const existing = await prisma.quotations.findFirst({
where: { quotation_number: number }, where: { quotation_number: number },
}); });
return !!existing; 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). * Next offer/quotation number (consumes sequence).
* Verifies uniqueness against the quotations table; retries if taken. * Verifies uniqueness against the quotations table; retries if taken.

View File

@@ -3,6 +3,7 @@ import {
generateOfferNumber, generateOfferNumber,
previewOfferNumber, previewOfferNumber,
releaseOfferNumber, releaseOfferNumber,
isOfferNumberTaken,
} from "./numbering.service"; } from "./numbering.service";
interface QuotationItemInput { interface QuotationItemInput {
@@ -142,53 +143,64 @@ export async function createOffer(body: Record<string, any>) {
? String(body.quotation_number) ? String(body.quotation_number)
: await generateOfferNumber(); : await generateOfferNumber();
const quotation = await prisma.quotations.create({ if (body.quotation_number !== undefined && body.quotation_number !== null) {
data: { const taken = await isOfferNumberTaken(String(body.quotation_number));
quotation_number: quotationNumber, if (taken) {
project_code: body.project_code ? String(body.project_code) : null, return { error: "Číslo nabídky je již použito", status: 400 } as const;
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", return prisma.$transaction(async (tx) => {
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0, const quotation = await tx.quotations.create({
apply_vat: body.apply_vat !== false, data: {
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0, quotation_number: quotationNumber,
status: body.status ? String(body.status) : "active", project_code: body.project_code ? String(body.project_code) : null,
scope_title: body.scope_title ? String(body.scope_title) : null, customer_id: body.customer_id ? Number(body.customer_id) : null,
scope_description: body.scope_description valid_until: body.valid_until
? String(body.scope_description) ? new Date(String(body.valid_until))
: null, : 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>) { 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; return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
} }
await prisma.quotations.update({ const data = {
where: { id }, customer_id:
data: { body.customer_id !== undefined ? Number(body.customer_id) : undefined,
customer_id: valid_until:
body.customer_id !== undefined ? Number(body.customer_id) : undefined, body.valid_until !== undefined
valid_until: ? body.valid_until
body.valid_until !== undefined ? new Date(String(body.valid_until))
? body.valid_until : null
? new Date(String(body.valid_until)) : undefined,
: null currency: body.currency !== undefined ? String(body.currency) : undefined,
: undefined, language: body.language !== undefined ? String(body.language) : undefined,
currency: body.currency !== undefined ? String(body.currency) : undefined, vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
language: body.language !== undefined ? String(body.language) : undefined, apply_vat:
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined, body.apply_vat !== undefined
apply_vat: ? body.apply_vat === true ||
body.apply_vat !== undefined body.apply_vat === 1 ||
? body.apply_vat === true || body.apply_vat === "1"
body.apply_vat === 1 || : undefined,
body.apply_vat === "1" exchange_rate:
: undefined, body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
exchange_rate: status: body.status !== undefined ? String(body.status) : undefined,
body.exchange_rate !== undefined project_code:
? Number(body.exchange_rate) body.project_code !== undefined
: undefined, ? body.project_code
status: body.status !== undefined ? String(body.status) : undefined, ? String(body.project_code)
project_code: : null
body.project_code !== undefined : undefined,
? body.project_code scope_title:
? String(body.project_code) body.scope_title !== undefined
: null ? body.scope_title
: undefined, ? String(body.scope_title)
scope_title: : null
body.scope_title !== undefined : undefined,
? body.scope_title scope_description:
? String(body.scope_title) body.scope_description !== undefined
: null ? body.scope_description
: undefined, ? String(body.scope_description)
scope_description: : null
body.scope_description !== undefined : undefined,
? body.scope_description modified_at: new Date(),
? String(body.scope_description) };
: null
: undefined,
modified_at: new Date(),
},
});
if (Array.isArray(body.items) || Array.isArray(body.sections)) { if (Array.isArray(body.items) || Array.isArray(body.sections)) {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.quotations.update({ where: { id }, data });
if (Array.isArray(body.items)) { if (Array.isArray(body.items)) {
await tx.quotation_items.deleteMany({ where: { quotation_id: id } }); await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
await tx.quotation_items.createMany({ 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 }; return { id, quotation_number: existing.quotation_number };

View File

@@ -3,6 +3,7 @@ import {
generateSharedNumber, generateSharedNumber,
previewSharedNumber, previewSharedNumber,
releaseSharedNumber, releaseSharedNumber,
isOrderNumberTaken,
} from "./numbering.service"; } from "./numbering.service";
interface OrderItemInput { interface OrderItemInput {
@@ -290,52 +291,61 @@ export async function createOrder(body: CreateOrderData) {
? String(body.order_number) ? String(body.order_number)
: await generateSharedNumber(); : await generateSharedNumber();
const order = await prisma.orders.create({ if (body.order_number !== undefined && body.order_number !== null) {
data: { const taken = await isOrderNumberTaken(String(body.order_number));
order_number: orderNumber, if (taken) {
customer_order_number: body.customer_order_number ?? null, return { error: "Číslo objednávky je již použito", status: 400 } as const;
quotation_id: body.quotation_id ?? null, }
customer_id: body.customer_id ?? null, }
status: body.status,
currency: body.currency, return prisma.$transaction(async (tx) => {
language: body.language, const order = await tx.orders.create({
vat_rate: body.vat_rate, data: {
apply_vat: body.apply_vat !== false, order_number: orderNumber,
exchange_rate: body.exchange_rate, customer_order_number: body.customer_order_number ?? null,
scope_title: body.scope_title ?? null, quotation_id: body.quotation_id ?? null,
scope_description: body.scope_description ?? null, customer_id: body.customer_id ?? null,
notes: body.notes ?? 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 { interface UpdateOrderData {
@@ -393,24 +403,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
data.apply_vat = data.apply_vat =
body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1"; 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 (Array.isArray(body.items) || Array.isArray(body.sections)) {
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") { if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
return { return {
@@ -419,6 +411,24 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
} as const; } as const;
} }
await prisma.$transaction(async (tx) => { 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)) { if (Array.isArray(body.items)) {
await tx.order_items.deleteMany({ where: { order_id: id } }); await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_items.createMany({ 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 } }; return { data: { id, order_number: existing.order_number } };
@@ -478,17 +506,22 @@ export async function deleteOrder(id: number) {
await prisma.orders.delete({ where: { id } }); await prisma.orders.delete({ where: { id } });
const releasedYears = new Set<number>();
const year = existing.created_at const year = existing.created_at
? new Date(existing.created_at).getFullYear() ? new Date(existing.created_at).getFullYear()
: new Date().getFullYear(); : new Date().getFullYear();
await releaseSharedNumber(year); await releaseSharedNumber(year);
releasedYears.add(year);
// Release the linked project's shared number(s) too // Release the linked project's shared number(s) too
for (const p of linkedProjects) { for (const p of linkedProjects) {
const pYear = p.created_at const pYear = p.created_at
? new Date(p.created_at).getFullYear() ? new Date(p.created_at).getFullYear()
: new Date().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 } }; return { data: { id, order_number: existing.order_number } };

View File

@@ -56,13 +56,13 @@ export async function listProjects(params: ListProjectsParams) {
prisma.projects.count({ where }), prisma.projects.count({ where }),
]); ]);
const enriched = projects.map((p) => ({ const enriched = projects.map(({ customers, users, orders, ...p }) => ({
...p, ...p,
customer_name: p.customers?.name || null, customer_name: customers?.name || null,
responsible_user_name: p.users responsible_user_name: users
? `${p.users.first_name} ${p.users.last_name}`.trim() ? `${users.first_name} ${users.last_name}`.trim()
: null, : null,
order_number: p.orders?.order_number || null, order_number: orders?.order_number || null,
})); }));
return { data: enriched, total, page, limit }; return { data: enriched, total, page, limit };
@@ -72,10 +72,10 @@ export async function getProject(id: number) {
const project = await prisma.projects.findUnique({ const project = await prisma.projects.findUnique({
where: { id }, where: { id },
include: { include: {
customers: true, customers: { select: { id: true, name: true } },
users: true, users: { select: { id: true, first_name: true, last_name: true } },
quotations: true, quotations: { select: { id: true, quotation_number: true } },
orders: true, orders: { select: { id: true, order_number: true, status: true } },
project_notes: { orderBy: { created_at: "desc" } }, 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>) { export async function createProject(body: Record<string, any>) {
const projectNumber = const projectNumber =
body.project_number !== undefined && body.project_number !== null body.project_number !== undefined && body.project_number !== null
? String(body.project_number) ? String(body.project_number)
: await generateSharedNumber(); : 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({ const project = await prisma.projects.create({
data: { data: {
project_number: projectNumber, project_number: projectNumber,
name: body.name ? String(body.name) : null, name: body.name ? String(body.name) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null, customer_id: body.customer_id != null ? Number(body.customer_id) : null,
responsible_user_id: body.responsible_user_id responsible_user_id:
? Number(body.responsible_user_id) body.responsible_user_id != null
: null, ? Number(body.responsible_user_id)
quotation_id: body.quotation_id ? Number(body.quotation_id) : null, : null,
order_id: body.order_id ? Number(body.order_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", status: body.status ? String(body.status) : "aktivni",
start_date: body.start_date ? new Date(String(body.start_date)) : null, start_date: body.start_date ? new Date(String(body.start_date)) : null,
end_date: body.end_date ? new Date(String(body.end_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) for (const f of strFields)
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null; if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
if (body.customer_id !== undefined) 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) if (body.responsible_user_id !== undefined)
data.responsible_user_id = body.responsible_user_id data.responsible_user_id =
? Number(body.responsible_user_id) body.responsible_user_id != null
: null; ? Number(body.responsible_user_id)
: null;
if (body.quotation_id !== undefined) 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) 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) if (body.start_date !== undefined)
data.start_date = body.start_date data.start_date = body.start_date
? new Date(String(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) if (body.end_date !== undefined)
data.end_date = body.end_date ? new Date(String(body.end_date)) : null; 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 ( if (
body.name !== undefined && 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) { export async function deleteProject(id: number, deleteFiles: boolean = false) {

View File

@@ -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 username = data.username.trim();
const email = data.email.trim(); const email = data.email.trim();
const firstName = data.first_name.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; 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( const passwordHash = await bcrypt.hash(
data.password, data.password,
config.security.bcryptCost, config.security.bcryptCost,
@@ -133,12 +145,71 @@ export async function createUser(data: CreateUserData) {
return { user } as const; 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 } }); const existing = await prisma.users.findUnique({ where: { id } });
if (!existing) { if (!existing) {
return { error: "Uživatel nenalezen", status: 404 } as const; 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> = {}; const data: Record<string, unknown> = {};
if (body.username !== undefined) { if (body.username !== undefined) {

View File

@@ -128,4 +128,5 @@ export type EntityType =
| "bank_account" | "bank_account"
| "company_settings" | "company_settings"
| "leave_balance" | "leave_balance"
| "project_file"; | "project_file"
| "audit_logs";

View File

@@ -40,7 +40,11 @@ export async function htmlToPdf(html: string): Promise<Buffer> {
}); });
return Buffer.from(pdf); return Buffer.from(pdf);
} finally { } finally {
await page.close(); try {
await page.close();
} catch {
await closeBrowser();
}
} }
} }

View File

@@ -9,6 +9,6 @@ export default defineConfig({
setupFiles: ["./src/__tests__/setup.ts"], setupFiles: ["./src/__tests__/setup.ts"],
testTimeout: 15000, testTimeout: 15000,
hookTimeout: 15000, hookTimeout: 15000,
exclude: ["dist/**", "node_modules/**"], exclude: ["dist/**", "node_modules/**", ".claude/**"],
}, },
}); });