From aff52e56722a74e0c06c8faa75d3842ac4eb88df Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 18 Apr 2026 15:34:57 -0400 Subject: [PATCH] Phase 1a: shared schemas, service layer, server tooling - shared/schemas/: move Zod schemas out of routes so client + server share them - shared/types.ts: inferred types and enums for cross-package use - server tsconfig rootDir raised to ".." so shared/ compiles in-tree - server/src/services/: ticket, comment, cti, user, auth, notification (stub), search (stub) - Routes thinned to validate-delegate-return; business logic now testable in isolation - server/src/lib/httpError.ts: typed HttpError replaces ad-hoc throw shapes - server/src/lib/logger.ts: pino structured logging replaces console.log - autoClose job delegates to ticketService.closeStale() - express-rate-limit on /api/auth/login (10 / 15min / IP) - vitest + vitest-mock-extended; 20 service-level tests cover auth, ticket, comment, user flows - CI: lint + test jobs before docker builds Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/build.yml | 6 +- package-lock.json | 12 + package.json | 3 + server/Dockerfile | 20 +- server/package-lock.json | 1652 +------------------- server/package.json | 7 +- server/src/index.ts | 17 +- server/src/jobs/autoClose.ts | 21 +- server/src/lib/httpError.ts | 9 + server/src/lib/logger.ts | 17 + server/src/middleware/errorHandler.ts | 3 +- server/src/routes/auth.ts | 48 +- server/src/routes/comments.ts | 48 +- server/src/routes/cti.ts | 84 +- server/src/routes/tickets.ts | 247 +-- server/src/routes/users.ts | 100 +- server/src/services/authService.test.ts | 71 + server/src/services/authService.ts | 33 + server/src/services/commentService.test.ts | 83 + server/src/services/commentService.ts | 45 + server/src/services/ctiService.ts | 77 + server/src/services/notificationService.ts | 22 + server/src/services/searchService.ts | 8 + server/src/services/ticketService.test.ts | 134 ++ server/src/services/ticketService.ts | 239 +++ server/src/services/userService.test.ts | 61 + server/src/services/userService.ts | 87 ++ server/src/test/setup.ts | 30 + server/tsconfig.json | 6 +- server/vitest.config.ts | 10 + shared/schemas/auth.ts | 8 + shared/schemas/comment.ts | 7 + shared/schemas/cti.ts | 19 + shared/schemas/enums.ts | 14 + shared/schemas/index.ts | 6 + shared/schemas/ticket.ts | 26 + shared/schemas/user.ts | 21 + shared/types.ts | 78 + 38 files changed, 1260 insertions(+), 2119 deletions(-) create mode 100644 server/src/lib/httpError.ts create mode 100644 server/src/lib/logger.ts create mode 100644 server/src/services/authService.test.ts create mode 100644 server/src/services/authService.ts create mode 100644 server/src/services/commentService.test.ts create mode 100644 server/src/services/commentService.ts create mode 100644 server/src/services/ctiService.ts create mode 100644 server/src/services/notificationService.ts create mode 100644 server/src/services/searchService.ts create mode 100644 server/src/services/ticketService.test.ts create mode 100644 server/src/services/ticketService.ts create mode 100644 server/src/services/userService.test.ts create mode 100644 server/src/services/userService.ts create mode 100644 server/src/test/setup.ts create mode 100644 server/vitest.config.ts create mode 100644 shared/schemas/auth.ts create mode 100644 shared/schemas/comment.ts create mode 100644 shared/schemas/cti.ts create mode 100644 shared/schemas/enums.ts create mode 100644 shared/schemas/index.ts create mode 100644 shared/schemas/ticket.ts create mode 100644 shared/schemas/user.ts create mode 100644 shared/types.ts diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 7645ddf..6e519c6 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -50,7 +50,8 @@ jobs: - name: Build and push server uses: docker/build-push-action@v6 with: - context: ./server + context: . + file: ./server/Dockerfile push: true tags: | ${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-server:latest @@ -77,7 +78,8 @@ jobs: - name: Build and push client uses: docker/build-push-action@v6 with: - context: ./client + context: . + file: ./client/Dockerfile push: true tags: | ${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-client:latest diff --git a/package-lock.json b/package-lock.json index 8d6df03..9f7ee67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "ticketing-system", "version": "1.0.0", + "dependencies": { + "zod": "^3.23.8" + }, "devDependencies": { "@eslint/js": "^9.17.0", "eslint": "^9.17.0", @@ -3501,6 +3504,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index f2e8882..77cc3aa 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "typecheck": "npm run typecheck --prefix server && npm run typecheck --prefix client", "test": "npm test --prefix server && npm test --prefix client" }, + "dependencies": { + "zod": "^3.23.8" + }, "devDependencies": { "@eslint/js": "^9.17.0", "eslint": "^9.17.0", diff --git a/server/Dockerfile b/server/Dockerfile index 401099a..fcb1e28 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,16 +1,16 @@ FROM node:22-alpine AS build WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -RUN npx prisma generate -RUN npm run build +COPY server/package*.json ./server/ +RUN cd server && npm ci +COPY server ./server +COPY shared ./shared +RUN cd server && npx prisma generate && npm run build FROM node:22-alpine RUN apk add --no-cache openssl -WORKDIR /app -COPY --from=build /app/dist ./dist -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/prisma ./prisma -COPY package*.json ./ +WORKDIR /app/server +COPY --from=build /app/server/dist ./dist +COPY --from=build /app/server/node_modules ./node_modules +COPY --from=build /app/server/prisma ./prisma +COPY --from=build /app/server/package*.json ./ CMD ["npm", "run", "start:prod"] diff --git a/server/package-lock.json b/server/package-lock.json index 64736ad..0b706b2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -23,7 +23,6 @@ "zod": "^3.23.8" }, "devDependencies": { - "@eslint/js": "^9.17.0", "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -31,15 +30,12 @@ "@types/node": "^22.10.0", "@types/node-cron": "^3.0.11", "@types/supertest": "^6.0.2", - "eslint": "^9.17.0", - "globals": "^15.14.0", - "prettier": "^3.4.2", "prisma": "^5.22.0", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.2", - "typescript-eslint": "^8.18.2", - "vitest": "^2.1.8" + "vitest": "^2.1.8", + "vitest-mock-extended": "^4.0.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -484,279 +480,6 @@ "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@eslint/config-array/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -815,14 +538,14 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -836,14 +559,14 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -855,7 +578,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" @@ -1335,13 +1058,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -1462,388 +1178,6 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", - "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/type-utils": "8.58.2", - "@typescript-eslint/utils": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.58.2", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", - "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", - "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.2", - "@typescript-eslint/types": "^8.58.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/project-service/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", - "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", - "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", - "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/types": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", - "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", - "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.58.2", - "@typescript-eslint/tsconfig-utils": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", - "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", - "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.2", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1970,69 +1304,6 @@ "node": ">= 0.6" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2072,13 +1343,6 @@ "node": ">=8.0.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -2109,17 +1373,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -2174,16 +1427,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2201,23 +1444,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -2228,26 +1454,6 @@ "node": ">= 16" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -2277,13 +1483,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2344,21 +1543,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -2387,13 +1571,6 @@ "node": ">=6" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2594,188 +1771,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/eslint/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2786,16 +1781,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2891,64 +1876,12 @@ "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", "license": "MIT" }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -2967,44 +1900,6 @@ "node": ">= 0.8" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -3062,6 +1957,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3140,32 +2036,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3178,16 +2048,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3266,43 +2126,6 @@ "node": ">=0.10.0" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3318,36 +2141,6 @@ "node": ">= 0.10" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -3357,40 +2150,6 @@ "node": ">=10" } }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -3440,46 +2199,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3516,13 +2235,6 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -3615,19 +2327,6 @@ "node": ">= 0.6" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -3662,13 +2361,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -3741,69 +2433,6 @@ "wrappy": "1" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3813,26 +2442,6 @@ "node": ">= 0.8" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -3863,19 +2472,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -3999,37 +2595,11 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/prisma": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -4084,16 +2654,6 @@ "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -4148,16 +2708,6 @@ "node": ">= 12.13.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4327,29 +2877,6 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -4480,19 +3007,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -4577,19 +3091,6 @@ "node": ">=6.6.0" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -4613,23 +3114,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -4669,17 +3153,19 @@ "node": ">=0.6" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "node_modules/ts-essentials": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.1.tgz", + "integrity": "sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18.12" - }, "peerDependencies": { - "typescript": ">=4.8.4" + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/tsx": { @@ -4702,19 +3188,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -4742,30 +3215,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", - "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.2", - "@typescript-eslint/parser": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4782,16 +3231,6 @@ "node": ">= 0.8" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -5423,6 +3862,20 @@ } } }, + "node_modules/vitest-mock-extended": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-4.0.0.tgz", + "integrity": "sha512-m2FmH8JYfxzZoLsHuhXRY+Pv++a3zd91HYpSz81tpRLEHbtFkEL2QcWvJowucWuNTirzQURKfWbJJSXbYqkTsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": ">=10.0.0" + }, + "peerDependencies": { + "typescript": "3.x || 4.x || 5.x || 6.x", + "vitest": ">=4.0.0" + } + }, "node_modules/vitest/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5448,22 +3901,6 @@ "dev": true, "license": "MIT" }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5481,35 +3918,12 @@ "node": ">=8" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/server/package.json b/server/package.json index 09b4e69..089d724 100644 --- a/server/package.json +++ b/server/package.json @@ -4,8 +4,8 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js", - "start:prod": "prisma db push && node dist/index.js", + "start": "node dist/server/src/index.js", + "start:prod": "prisma db push && node dist/server/src/index.js", "db:migrate": "prisma migrate dev", "db:push": "prisma db push", "db:generate": "prisma generate", @@ -41,6 +41,7 @@ "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.2", - "vitest": "^2.1.8" + "vitest": "^2.1.8", + "vitest-mock-extended": "^4.0.0" } } diff --git a/server/src/index.ts b/server/src/index.ts index 32eff68..418de62 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,6 +2,8 @@ import 'express-async-errors'; import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; +import pinoHttp from 'pino-http'; +import rateLimit from 'express-rate-limit'; import authRoutes from './routes/auth'; import ticketRoutes from './routes/tickets'; @@ -10,20 +12,31 @@ import userRoutes from './routes/users'; import { authenticate } from './middleware/auth'; import { errorHandler } from './middleware/errorHandler'; import { startAutoCloseJob } from './jobs/autoClose'; +import { logger } from './lib/logger'; dotenv.config(); if (!process.env.JWT_SECRET) { - console.error('FATAL: JWT_SECRET is not set'); + logger.fatal('JWT_SECRET is not set'); process.exit(1); } const app = express(); +app.use(pinoHttp({ logger })); app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' })); app.use(express.json()); +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many login attempts. Try again in 15 minutes.' }, +}); + // Public +app.use('/api/auth/login', loginLimiter); app.use('/api/auth', authRoutes); // Protected @@ -37,5 +50,5 @@ startAutoCloseJob(); const PORT = Number(process.env.PORT) || 3000; app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); + logger.info({ port: PORT }, 'Server started'); }); diff --git a/server/src/jobs/autoClose.ts b/server/src/jobs/autoClose.ts index 4187df1..a8e3dc6 100644 --- a/server/src/jobs/autoClose.ts +++ b/server/src/jobs/autoClose.ts @@ -1,24 +1,15 @@ import cron from 'node-cron'; -import prisma from '../lib/prisma'; +import { closeStale } from '../services/ticketService'; +import { logger } from '../lib/logger'; export function startAutoCloseJob() { // Run every hour — closes RESOLVED tickets that have been resolved for 14+ days cron.schedule('0 * * * *', async () => { - const twoWeeksAgo = new Date(); - twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); - - const result = await prisma.ticket.updateMany({ - where: { - status: 'RESOLVED', - resolvedAt: { lte: twoWeeksAgo }, - }, - data: { status: 'CLOSED' }, - }); - - if (result.count > 0) { - console.log(`[AutoClose] Closed ${result.count} ticket(s) after 2-week resolution period`); + const count = await closeStale(14); + if (count > 0) { + logger.info({ count }, 'AutoClose: closed stale resolved tickets'); } }); - console.log('[AutoClose] Job scheduled — runs every hour'); + logger.info('AutoClose: job scheduled — runs every hour'); } diff --git a/server/src/lib/httpError.ts b/server/src/lib/httpError.ts new file mode 100644 index 0000000..ee8061b --- /dev/null +++ b/server/src/lib/httpError.ts @@ -0,0 +1,9 @@ +export class HttpError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = 'HttpError'; + } +} diff --git a/server/src/lib/logger.ts b/server/src/lib/logger.ts new file mode 100644 index 0000000..8edf5b1 --- /dev/null +++ b/server/src/lib/logger.ts @@ -0,0 +1,17 @@ +import pino from 'pino'; + +const isDev = process.env.NODE_ENV !== 'production'; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'), + transport: isDev + ? { + target: 'pino-pretty', + options: { colorize: true, translateTime: 'SYS:HH:MM:ss.l', ignore: 'pid,hostname' }, + } + : undefined, + redact: { + paths: ['req.headers.authorization', 'req.headers["x-api-key"]', '*.password', '*.passwordHash'], + remove: true, + }, +}); diff --git a/server/src/middleware/errorHandler.ts b/server/src/middleware/errorHandler.ts index 32332c6..2ac7377 100644 --- a/server/src/middleware/errorHandler.ts +++ b/server/src/middleware/errorHandler.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { ZodError } from 'zod'; +import { logger } from '../lib/logger'; type ErrorLike = { code?: string; @@ -9,7 +10,7 @@ type ErrorLike = { }; export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { - console.error(err); + logger.error({ err }, 'Request failed'); if (err instanceof ZodError) { return res.status(400).json({ error: 'Validation error', details: err.flatten() }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index fc8b21f..b2b50e8 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,53 +1,19 @@ import { Router } from 'express'; -import bcrypt from 'bcryptjs'; -import jwt from 'jsonwebtoken'; -import { z } from 'zod'; -import prisma from '../lib/prisma'; import { authenticate, AuthRequest } from '../middleware/auth'; +import { loginSchema } from '../../../shared/schemas/auth'; +import * as authService from '../services/authService'; +import * as userService from '../services/userService'; const router = Router(); -const loginSchema = z.object({ - username: z.string().min(1), - password: z.string().min(1), -}); - router.post('/login', async (req, res) => { - const { username, password } = loginSchema.parse(req.body); - - const user = await prisma.user.findUnique({ where: { username } }); - if (!user || !(await bcrypt.compare(password, user.passwordHash))) { - return res.status(401).json({ error: 'Invalid credentials' }); - } - - if (user.role === 'SERVICE') { - return res.status(401).json({ error: 'Service accounts must authenticate via API key' }); - } - - const token = jwt.sign( - { id: user.id, role: user.role, username: user.username }, - process.env.JWT_SECRET!, - { expiresIn: '24h' }, - ); - - res.json({ - token, - user: { - id: user.id, - username: user.username, - displayName: user.displayName, - email: user.email, - role: user.role, - }, - }); + const input = loginSchema.parse(req.body); + const result = await authService.login(input); + res.json(result); }); router.get('/me', authenticate, async (req: AuthRequest, res) => { - const user = await prisma.user.findUnique({ - where: { id: req.user!.id }, - select: { id: true, username: true, displayName: true, email: true, role: true }, - }); - if (!user) return res.status(404).json({ error: 'User not found' }); + const user = await userService.getCurrentUser(req.user!.id); res.json(user); }); diff --git a/server/src/routes/comments.ts b/server/src/routes/comments.ts index 1bc2685..95e3091 100644 --- a/server/src/routes/comments.ts +++ b/server/src/routes/comments.ts @@ -1,58 +1,22 @@ import { Router } from 'express'; -import { z } from 'zod'; -import prisma from '../lib/prisma'; import { AuthRequest } from '../middleware/auth'; +import { commentSchema } from '../../../shared/schemas/comment'; +import * as commentService from '../services/commentService'; const router = Router({ mergeParams: true }); -const commentSchema = z.object({ - body: z.string().min(1), -}); - router.post('/', async (req: AuthRequest, res) => { const { body } = commentSchema.parse(req.body); const ticketId = (req.params as Record).ticketId; - - const ticket = await prisma.ticket.findFirst({ - where: { OR: [{ id: ticketId }, { displayId: ticketId }] }, - }); - if (!ticket) return res.status(404).json({ error: 'Ticket not found' }); - - const [comment] = await prisma.$transaction([ - prisma.comment.create({ - data: { body, ticketId: ticket.id, authorId: req.user!.id }, - include: { author: { select: { id: true, username: true, displayName: true } } }, - }), - prisma.auditLog.create({ - data: { ticketId: ticket.id, userId: req.user!.id, action: 'COMMENT_ADDED', detail: body }, - }), - ]); - + const comment = await commentService.addComment(ticketId, body, req.user!.id); res.status(201).json(comment); }); router.delete('/:commentId', async (req: AuthRequest, res) => { - const comment = await prisma.comment.findUnique({ - where: { id: req.params.commentId }, + await commentService.deleteComment(req.params.commentId, { + id: req.user!.id, + role: req.user!.role, }); - if (!comment) return res.status(404).json({ error: 'Comment not found' }); - - if (comment.authorId !== req.user!.id && req.user!.role !== 'ADMIN') { - return res.status(403).json({ error: 'Not allowed' }); - } - - await prisma.$transaction([ - prisma.comment.delete({ where: { id: req.params.commentId } }), - prisma.auditLog.create({ - data: { - ticketId: comment.ticketId, - userId: req.user!.id, - action: 'COMMENT_DELETED', - detail: comment.body, - }, - }), - ]); - res.status(204).send(); }); diff --git a/server/src/routes/cti.ts b/server/src/routes/cti.ts index 010318a..18c702b 100644 --- a/server/src/routes/cti.ts +++ b/server/src/routes/cti.ts @@ -1,112 +1,74 @@ import { Router } from 'express'; -import { z } from 'zod'; -import prisma from '../lib/prisma'; import { requireAdmin } from '../middleware/auth'; +import { + ctiNameSchema as nameSchema, + createTypeSchema, + createItemSchema, +} from '../../../shared/schemas/cti'; +import * as ctiService from '../services/ctiService'; const router = Router(); -const nameSchema = z.object({ name: z.string().min(1).max(100) }); - -// ── Categories ──────────────────────────────────────────────────────────────── +// ── Categories ─────────────────────────────────────────────────────────────── router.get('/categories', async (_req, res) => { - const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } }); - res.json(categories); + res.json(await ctiService.listCategories()); }); router.post('/categories', requireAdmin, async (req, res) => { const { name } = nameSchema.parse(req.body); - const category = await prisma.category.create({ data: { name } }); - res.status(201).json(category); + res.status(201).json(await ctiService.createCategory(name)); }); router.put('/categories/:id', requireAdmin, async (req, res) => { const { name } = nameSchema.parse(req.body); - const category = await prisma.category.update({ - where: { id: req.params.id }, - data: { name }, - }); - res.json(category); + res.json(await ctiService.updateCategory(req.params.id, name)); }); router.delete('/categories/:id', requireAdmin, async (req, res) => { - await prisma.category.delete({ where: { id: req.params.id } }); + await ctiService.deleteCategory(req.params.id); res.status(204).send(); }); -// ── Types ───────────────────────────────────────────────────────────────────── +// ── Types ──────────────────────────────────────────────────────────────────── router.get('/types', async (req, res) => { - const { categoryId } = req.query; - const types = await prisma.type.findMany({ - where: categoryId ? { categoryId: categoryId as string } : undefined, - include: { category: true }, - orderBy: { name: 'asc' }, - }); - res.json(types); + res.json(await ctiService.listTypes(req.query.categoryId as string | undefined)); }); router.post('/types', requireAdmin, async (req, res) => { - const { name, categoryId } = z - .object({ name: z.string().min(1).max(100), categoryId: z.string().min(1) }) - .parse(req.body); - const type = await prisma.type.create({ - data: { name, categoryId }, - include: { category: true }, - }); - res.status(201).json(type); + const { name, categoryId } = createTypeSchema.parse(req.body); + res.status(201).json(await ctiService.createType(name, categoryId)); }); router.put('/types/:id', requireAdmin, async (req, res) => { const { name } = nameSchema.parse(req.body); - const type = await prisma.type.update({ - where: { id: req.params.id }, - data: { name }, - include: { category: true }, - }); - res.json(type); + res.json(await ctiService.updateType(req.params.id, name)); }); router.delete('/types/:id', requireAdmin, async (req, res) => { - await prisma.type.delete({ where: { id: req.params.id } }); + await ctiService.deleteType(req.params.id); res.status(204).send(); }); -// ── Items ───────────────────────────────────────────────────────────────────── +// ── Items ──────────────────────────────────────────────────────────────────── router.get('/items', async (req, res) => { - const { typeId } = req.query; - const items = await prisma.item.findMany({ - where: typeId ? { typeId: typeId as string } : undefined, - include: { type: { include: { category: true } } }, - orderBy: { name: 'asc' }, - }); - res.json(items); + res.json(await ctiService.listItems(req.query.typeId as string | undefined)); }); router.post('/items', requireAdmin, async (req, res) => { - const { name, typeId } = z - .object({ name: z.string().min(1).max(100), typeId: z.string().min(1) }) - .parse(req.body); - const item = await prisma.item.create({ - data: { name, typeId }, - include: { type: { include: { category: true } } }, - }); - res.status(201).json(item); + const { name, typeId } = createItemSchema.parse(req.body); + res.status(201).json(await ctiService.createItem(name, typeId)); }); router.put('/items/:id', requireAdmin, async (req, res) => { const { name } = nameSchema.parse(req.body); - const item = await prisma.item.update({ - where: { id: req.params.id }, - data: { name }, - include: { type: { include: { category: true } } }, - }); - res.json(item); + res.json(await ctiService.updateItem(req.params.id, name)); }); router.delete('/items/:id', requireAdmin, async (req, res) => { - await prisma.item.delete({ where: { id: req.params.id } }); + await ctiService.deleteItem(req.params.id); res.status(204).send(); }); diff --git a/server/src/routes/tickets.ts b/server/src/routes/tickets.ts index 3243080..c652975 100644 --- a/server/src/routes/tickets.ts +++ b/server/src/routes/tickets.ts @@ -1,263 +1,54 @@ import { Router } from 'express'; -import { z } from 'zod'; -import prisma from '../lib/prisma'; import { requireAdmin, requireAgent, AuthRequest } from '../middleware/auth'; import commentRouter from './comments'; +import { createTicketSchema, updateTicketSchema } from '../../../shared/schemas/ticket'; +import * as ticketService from '../services/ticketService'; const router = Router(); -const ticketInclude = { - category: true, - type: true, - item: true, - assignee: { select: { id: true, username: true, displayName: true } }, - createdBy: { select: { id: true, username: true, displayName: true } }, - comments: { - include: { author: { select: { id: true, username: true, displayName: true } } }, - orderBy: { createdAt: 'asc' as const }, - }, -} as const; - -const STATUS_LABELS: Record = { - OPEN: 'Open', - IN_PROGRESS: 'In Progress', - RESOLVED: 'Resolved', - CLOSED: 'Closed', -}; - -async function generateDisplayId(): Promise { - while (true) { - const num = Math.floor(Math.random() * 900_000_000) + 100_000_000; - const displayId = `V${num}`; - const exists = await prisma.ticket.findUnique({ where: { displayId } }); - if (!exists) return displayId; - } -} - -// Look up ticket by internal id or displayId -function findByIdOrDisplay(idOrDisplay: string) { - return prisma.ticket.findFirst({ - where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] }, - }); -} - -const createSchema = z.object({ - title: z.string().min(1).max(255), - overview: z.string().min(1), - severity: z.number().int().min(1).max(5), - categoryId: z.string().min(1), - typeId: z.string().min(1), - itemId: z.string().min(1), - assigneeId: z.string().optional(), -}); - -const updateSchema = z.object({ - title: z.string().min(1).max(255).optional(), - overview: z.string().min(1).optional(), - severity: z.number().int().min(1).max(5).optional(), - status: z.enum(['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED']).optional(), - categoryId: z.string().min(1).optional(), - typeId: z.string().min(1).optional(), - itemId: z.string().min(1).optional(), - assigneeId: z.string().nullable().optional(), -}); - -// Mount comment sub-router router.use('/:ticketId/comments', commentRouter); -// GET /api/tickets router.get('/', async (req: AuthRequest, res) => { const { status, severity, assigneeId, categoryId, typeId, itemId, search } = req.query; - - const where: Record = {}; - if (status) where.status = status; - if (severity) where.severity = Number(severity); - if (assigneeId) where.assigneeId = assigneeId; - if (itemId) where.itemId = itemId; - else if (typeId) where.typeId = typeId; - else if (categoryId) where.categoryId = categoryId; - if (search) { - where.OR = [ - { title: { contains: search as string, mode: 'insensitive' } }, - { overview: { contains: search as string, mode: 'insensitive' } }, - { displayId: { contains: search as string, mode: 'insensitive' } }, - ]; - } - - const tickets = await prisma.ticket.findMany({ - where, - include: { - category: true, - type: true, - item: true, - assignee: { select: { id: true, username: true, displayName: true } }, - createdBy: { select: { id: true, username: true, displayName: true } }, - _count: { select: { comments: true } }, - }, - orderBy: [{ severity: 'asc' }, { createdAt: 'desc' }], + const tickets = await ticketService.listTickets({ + status: status as string | undefined, + severity: severity ? Number(severity) : undefined, + assigneeId: assigneeId as string | undefined, + categoryId: categoryId as string | undefined, + typeId: typeId as string | undefined, + itemId: itemId as string | undefined, + search: search as string | undefined, }); - res.json(tickets); }); -// GET /api/tickets/:id router.get('/:id', async (req, res) => { - const ticket = await prisma.ticket.findFirst({ - where: { OR: [{ id: req.params.id }, { displayId: req.params.id }] }, - include: ticketInclude, - }); - if (!ticket) return res.status(404).json({ error: 'Ticket not found' }); + const ticket = await ticketService.getTicket(req.params.id); res.json(ticket); }); -// GET /api/tickets/:id/audit router.get('/:id/audit', async (req, res) => { - const ticket = await findByIdOrDisplay(req.params.id); - if (!ticket) return res.status(404).json({ error: 'Ticket not found' }); - - const logs = await prisma.auditLog.findMany({ - where: { ticketId: ticket.id }, - include: { user: { select: { id: true, username: true, displayName: true } } }, - orderBy: { createdAt: 'desc' }, - }); - + const logs = await ticketService.getTicketAudit(req.params.id); res.json(logs); }); -// POST /api/tickets router.post('/', requireAgent, async (req: AuthRequest, res) => { - const data = createSchema.parse(req.body); - const displayId = await generateDisplayId(); - - const ticket = await prisma.$transaction(async (tx) => { - const created = await tx.ticket.create({ - data: { displayId, ...data, createdById: req.user!.id }, - }); - await tx.auditLog.create({ - data: { ticketId: created.id, userId: req.user!.id, action: 'CREATED' }, - }); - return tx.ticket.findUnique({ where: { id: created.id }, include: ticketInclude }); - }); - + const data = createTicketSchema.parse(req.body); + const ticket = await ticketService.createTicket(data, req.user!.id); res.status(201).json(ticket); }); -// PATCH /api/tickets/:id router.patch('/:id', requireAgent, async (req: AuthRequest, res) => { - const data = updateSchema.parse(req.body); - - // Only admins can set status to CLOSED - if (data.status === 'CLOSED' && req.user?.role !== 'ADMIN') { - return res.status(403).json({ error: 'Only admins can close tickets' }); - } - - const existing = await prisma.ticket.findFirst({ - where: { OR: [{ id: req.params.id }, { displayId: req.params.id }] }, - include: { - category: true, - type: true, - item: true, - assignee: { select: { displayName: true } }, - }, + const data = updateTicketSchema.parse(req.body); + const ticket = await ticketService.updateTicket(req.params.id, data, { + id: req.user!.id, + role: req.user!.role, }); - if (!existing) return res.status(404).json({ error: 'Ticket not found' }); - - // Build audit entries - const auditEntries: { action: string; detail?: string }[] = []; - - if (data.status && data.status !== existing.status) { - auditEntries.push({ - action: 'STATUS_CHANGED', - detail: `${STATUS_LABELS[existing.status]} → ${STATUS_LABELS[data.status]}`, - }); - } - - if ('assigneeId' in data && data.assigneeId !== existing.assigneeId) { - const newAssignee = data.assigneeId - ? await prisma.user.findUnique({ - where: { id: data.assigneeId }, - select: { displayName: true }, - }) - : null; - auditEntries.push({ - action: 'ASSIGNEE_CHANGED', - detail: `${existing.assignee?.displayName ?? 'Unassigned'} → ${newAssignee?.displayName ?? 'Unassigned'}`, - }); - } - - if (data.severity && data.severity !== existing.severity) { - auditEntries.push({ - action: 'SEVERITY_CHANGED', - detail: `SEV ${existing.severity} → SEV ${data.severity}`, - }); - } - - // CTI rerouting — only log if any CTI field actually changed - const ctiChanged = - (data.categoryId && data.categoryId !== existing.categoryId) || - (data.typeId && data.typeId !== existing.typeId) || - (data.itemId && data.itemId !== existing.itemId); - - if (ctiChanged) { - const [newCat, newType, newItem] = await Promise.all([ - data.categoryId && data.categoryId !== existing.categoryId - ? prisma.category.findUnique({ where: { id: data.categoryId } }) - : Promise.resolve(existing.category), - data.typeId && data.typeId !== existing.typeId - ? prisma.type.findUnique({ where: { id: data.typeId } }) - : Promise.resolve(existing.type), - data.itemId && data.itemId !== existing.itemId - ? prisma.item.findUnique({ where: { id: data.itemId } }) - : Promise.resolve(existing.item), - ]); - auditEntries.push({ - action: 'REROUTED', - detail: `${existing.category.name} › ${existing.type.name} › ${existing.item.name} → ${newCat?.name} › ${newType?.name} › ${newItem?.name}`, - }); - } - - if (data.title && data.title !== existing.title) { - auditEntries.push({ action: 'TITLE_CHANGED', detail: data.title }); - } - - if (data.overview && data.overview !== existing.overview) { - auditEntries.push({ action: 'OVERVIEW_CHANGED' }); - } - - // Handle resolvedAt tracking - const update: Record = { ...data }; - if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') { - update.resolvedAt = new Date(); - } else if (data.status && data.status !== 'RESOLVED' && existing.status === 'RESOLVED') { - update.resolvedAt = null; - } - - const ticket = await prisma.$transaction(async (tx) => { - const updated = await tx.ticket.update({ - where: { id: existing.id }, - data: update, - }); - if (auditEntries.length > 0) { - await tx.auditLog.createMany({ - data: auditEntries.map((e) => ({ - ticketId: existing.id, - userId: req.user!.id, - action: e.action, - detail: e.detail ?? null, - })), - }); - } - return tx.ticket.findUnique({ where: { id: updated.id }, include: ticketInclude }); - }); - res.json(ticket); }); -// DELETE /api/tickets/:id — admin only router.delete('/:id', requireAdmin, async (req, res) => { - const ticket = await findByIdOrDisplay(req.params.id); - if (!ticket) return res.status(404).json({ error: 'Ticket not found' }); - await prisma.ticket.delete({ where: { id: ticket.id } }); + await ticketService.deleteTicket(req.params.id); res.status(204).send(); }); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 7b9e611..981ba66 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -1,110 +1,26 @@ import { Router } from 'express'; -import bcrypt from 'bcryptjs'; -import crypto from 'crypto'; -import { z } from 'zod'; -import { Prisma } from '@prisma/client'; -import prisma from '../lib/prisma'; import { requireAdmin, AuthRequest } from '../middleware/auth'; +import { createUserSchema, updateUserSchema } from '../../../shared/schemas/user'; +import * as userService from '../services/userService'; const router = Router(); -const userSelect = { - id: true, - username: true, - displayName: true, - email: true, - role: true, - apiKey: true, - createdAt: true, -} as const; - router.get('/', async (_req, res) => { - const users = await prisma.user.findMany({ - select: { - id: true, - username: true, - displayName: true, - email: true, - role: true, - createdAt: true, - }, - orderBy: { displayName: 'asc' }, - }); - res.json(users); + res.json(await userService.listUsers()); }); router.post('/', requireAdmin, async (req, res) => { - const data = z - .object({ - username: z.string().min(1).max(50), - email: z.string().email(), - displayName: z.string().min(1).max(100), - password: z.string().min(8).optional(), - role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).default('AGENT'), - }) - .parse(req.body); - - const passwordHash = data.password - ? await bcrypt.hash(data.password, 12) - : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12); - - const apiKey = - data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined; - - const user = await prisma.user.create({ - data: { - username: data.username, - email: data.email, - displayName: data.displayName, - passwordHash, - role: data.role, - apiKey, - }, - select: userSelect, - }); - - res.status(201).json(user); + const data = createUserSchema.parse(req.body); + res.status(201).json(await userService.createUser(data)); }); router.patch('/:id', requireAdmin, async (req, res) => { - const data = z - .object({ - displayName: z.string().min(1).max(100).optional(), - email: z.string().email().optional(), - password: z.string().min(8).optional(), - role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).optional(), - regenerateApiKey: z.boolean().optional(), - }) - .parse(req.body); - - const update: Prisma.UserUpdateInput = {}; - if (data.displayName) update.displayName = data.displayName; - if (data.email) update.email = data.email; - if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12); - if (data.role) { - update.role = data.role; - if (data.role === 'SERVICE' && !update.apiKey) { - update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`; - } - } - if (data.regenerateApiKey) { - update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`; - } - - const user = await prisma.user.update({ - where: { id: req.params.id }, - data: update, - select: userSelect, - }); - - res.json(user); + const data = updateUserSchema.parse(req.body); + res.json(await userService.updateUser(req.params.id, data)); }); router.delete('/:id', requireAdmin, async (req: AuthRequest, res) => { - if (req.params.id === req.user!.id) { - return res.status(400).json({ error: 'Cannot delete your own account' }); - } - await prisma.user.delete({ where: { id: req.params.id } }); + await userService.deleteUser(req.params.id, req.user!.id); res.status(204).send(); }); diff --git a/server/src/services/authService.test.ts b/server/src/services/authService.test.ts new file mode 100644 index 0000000..c341275 --- /dev/null +++ b/server/src/services/authService.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { prismaMock } from '../test/setup'; +import { login } from './authService'; +import { HttpError } from '../lib/httpError'; + +describe('authService.login', () => { + it('returns token + user for valid credentials', async () => { + const password = 'password123'; + prismaMock.user.findUnique.mockResolvedValue({ + id: 'u1', + username: 'alice', + email: 'a@x.io', + displayName: 'Alice', + passwordHash: await bcrypt.hash(password, 4), + role: 'AGENT', + apiKey: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await login({ username: 'alice', password }); + expect(result.user).toMatchObject({ id: 'u1', username: 'alice', role: 'AGENT' }); + const decoded = jwt.verify(result.token, process.env.JWT_SECRET!) as { id: string }; + expect(decoded.id).toBe('u1'); + }); + + it('rejects invalid password', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'u1', + username: 'alice', + email: 'a@x.io', + displayName: 'Alice', + passwordHash: await bcrypt.hash('correct', 4), + role: 'AGENT', + apiKey: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect(login({ username: 'alice', password: 'wrong' })).rejects.toThrow(HttpError); + }); + + it('rejects unknown user', async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + await expect(login({ username: 'nobody', password: 'x' })).rejects.toMatchObject({ + status: 401, + }); + }); + + it('rejects SERVICE role from password login', async () => { + const password = 'svc-pw'; + prismaMock.user.findUnique.mockResolvedValue({ + id: 'svc', + username: 'goddard', + email: 'g@x.io', + displayName: 'Goddard', + passwordHash: await bcrypt.hash(password, 4), + role: 'SERVICE', + apiKey: 'sk_xyz', + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect(login({ username: 'goddard', password })).rejects.toMatchObject({ + status: 401, + message: expect.stringMatching(/API key/i), + }); + }); +}); diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts new file mode 100644 index 0000000..c12513a --- /dev/null +++ b/server/src/services/authService.ts @@ -0,0 +1,33 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import prisma from '../lib/prisma'; +import { HttpError } from '../lib/httpError'; +import type { LoginInput } from '../../../shared/schemas/auth'; + +export async function login({ username, password }: LoginInput) { + const user = await prisma.user.findUnique({ where: { username } }); + if (!user || !(await bcrypt.compare(password, user.passwordHash))) { + throw new HttpError(401, 'Invalid credentials'); + } + + if (user.role === 'SERVICE') { + throw new HttpError(401, 'Service accounts must authenticate via API key'); + } + + const token = jwt.sign( + { id: user.id, role: user.role, username: user.username }, + process.env.JWT_SECRET!, + { expiresIn: '24h' }, + ); + + return { + token, + user: { + id: user.id, + username: user.username, + displayName: user.displayName, + email: user.email, + role: user.role, + }, + }; +} diff --git a/server/src/services/commentService.test.ts b/server/src/services/commentService.test.ts new file mode 100644 index 0000000..32eef03 --- /dev/null +++ b/server/src/services/commentService.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { prismaMock } from '../test/setup'; +import { addComment, deleteComment } from './commentService'; +import { HttpError } from '../lib/httpError'; + +describe('commentService.addComment', () => { + it('404s when ticket not found', async () => { + prismaMock.ticket.findFirst.mockResolvedValue(null); + await expect(addComment('V1', 'body', 'u1')).rejects.toMatchObject({ status: 404 }); + }); + + it('accepts either id or displayId and writes audit + comment', async () => { + prismaMock.ticket.findFirst.mockResolvedValue({ + id: 'tid', + displayId: 'V111', + title: 't', + overview: 'o', + severity: 3, + status: 'OPEN', + categoryId: 'c', + typeId: 'ty', + itemId: 'i', + assigneeId: null, + createdById: 'u1', + createdAt: new Date(), + updatedAt: new Date(), + resolvedAt: null, + }); + prismaMock.comment.create.mockResolvedValue({ + id: 'cid', + body: 'hi', + ticketId: 'tid', + authorId: 'u1', + createdAt: new Date(), + }); + + await addComment('V111', 'hi', 'u1'); + expect(prismaMock.comment.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ ticketId: 'tid', body: 'hi' }) }), + ); + expect(prismaMock.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ action: 'COMMENT_ADDED', detail: 'hi' }), + }), + ); + }); +}); + +describe('commentService.deleteComment', () => { + const baseComment = { + id: 'cid', + body: 'x', + ticketId: 'tid', + authorId: 'author1', + createdAt: new Date(), + }; + + it('rejects non-author non-admin with 403', async () => { + prismaMock.comment.findUnique.mockResolvedValue(baseComment); + await expect( + deleteComment('cid', { id: 'other', role: 'AGENT' }), + ).rejects.toMatchObject({ status: 403 }); + }); + + it('allows the author to delete', async () => { + prismaMock.comment.findUnique.mockResolvedValue(baseComment); + await expect(deleteComment('cid', { id: 'author1', role: 'AGENT' })).resolves.toBeUndefined(); + expect(prismaMock.comment.delete).toHaveBeenCalled(); + }); + + it('allows admin to delete', async () => { + prismaMock.comment.findUnique.mockResolvedValue(baseComment); + await expect(deleteComment('cid', { id: 'other', role: 'ADMIN' })).resolves.toBeUndefined(); + expect(prismaMock.comment.delete).toHaveBeenCalled(); + }); + + it('404s when comment missing', async () => { + prismaMock.comment.findUnique.mockResolvedValue(null); + await expect( + deleteComment('missing', { id: 'u', role: 'ADMIN' }), + ).rejects.toThrow(HttpError); + }); +}); diff --git a/server/src/services/commentService.ts b/server/src/services/commentService.ts new file mode 100644 index 0000000..c39041f --- /dev/null +++ b/server/src/services/commentService.ts @@ -0,0 +1,45 @@ +import prisma from '../lib/prisma'; +import { HttpError } from '../lib/httpError'; + +export async function addComment(ticketIdOrDisplay: string, body: string, actorId: string) { + const ticket = await prisma.ticket.findFirst({ + where: { OR: [{ id: ticketIdOrDisplay }, { displayId: ticketIdOrDisplay }] }, + }); + if (!ticket) throw new HttpError(404, 'Ticket not found'); + + const [comment] = await prisma.$transaction([ + prisma.comment.create({ + data: { body, ticketId: ticket.id, authorId: actorId }, + include: { author: { select: { id: true, username: true, displayName: true } } }, + }), + prisma.auditLog.create({ + data: { ticketId: ticket.id, userId: actorId, action: 'COMMENT_ADDED', detail: body }, + }), + ]); + + return comment; +} + +export async function deleteComment( + commentId: string, + actor: { id: string; role: string }, +) { + const comment = await prisma.comment.findUnique({ where: { id: commentId } }); + if (!comment) throw new HttpError(404, 'Comment not found'); + + if (comment.authorId !== actor.id && actor.role !== 'ADMIN') { + throw new HttpError(403, 'Not allowed'); + } + + await prisma.$transaction([ + prisma.comment.delete({ where: { id: commentId } }), + prisma.auditLog.create({ + data: { + ticketId: comment.ticketId, + userId: actor.id, + action: 'COMMENT_DELETED', + detail: comment.body, + }, + }), + ]); +} diff --git a/server/src/services/ctiService.ts b/server/src/services/ctiService.ts new file mode 100644 index 0000000..25b90a9 --- /dev/null +++ b/server/src/services/ctiService.ts @@ -0,0 +1,77 @@ +import prisma from '../lib/prisma'; + +// ── Categories ─────────────────────────────────────────────────────────────── + +export function listCategories() { + return prisma.category.findMany({ orderBy: { name: 'asc' } }); +} + +export function createCategory(name: string) { + return prisma.category.create({ data: { name } }); +} + +export function updateCategory(id: string, name: string) { + return prisma.category.update({ where: { id }, data: { name } }); +} + +export async function deleteCategory(id: string) { + await prisma.category.delete({ where: { id } }); +} + +// ── Types ──────────────────────────────────────────────────────────────────── + +export function listTypes(categoryId?: string) { + return prisma.type.findMany({ + where: categoryId ? { categoryId } : undefined, + include: { category: true }, + orderBy: { name: 'asc' }, + }); +} + +export function createType(name: string, categoryId: string) { + return prisma.type.create({ + data: { name, categoryId }, + include: { category: true }, + }); +} + +export function updateType(id: string, name: string) { + return prisma.type.update({ + where: { id }, + data: { name }, + include: { category: true }, + }); +} + +export async function deleteType(id: string) { + await prisma.type.delete({ where: { id } }); +} + +// ── Items ──────────────────────────────────────────────────────────────────── + +export function listItems(typeId?: string) { + return prisma.item.findMany({ + where: typeId ? { typeId } : undefined, + include: { type: { include: { category: true } } }, + orderBy: { name: 'asc' }, + }); +} + +export function createItem(name: string, typeId: string) { + return prisma.item.create({ + data: { name, typeId }, + include: { type: { include: { category: true } } }, + }); +} + +export function updateItem(id: string, name: string) { + return prisma.item.update({ + where: { id }, + data: { name }, + include: { type: { include: { category: true } } }, + }); +} + +export async function deleteItem(id: string) { + await prisma.item.delete({ where: { id } }); +} diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts new file mode 100644 index 0000000..b97d636 --- /dev/null +++ b/server/src/services/notificationService.ts @@ -0,0 +1,22 @@ +// Stub — filled in Phase 2 (email, webhooks, in-app). +// Routes/services call these hooks; implementations land later. + +export async function notifyAssigned(_ticketId: string, _assigneeId: string): Promise { + return; +} + +export async function notifyStatusChanged( + _ticketId: string, + _from: string, + _to: string, +): Promise { + return; +} + +export async function notifyCommentAdded(_ticketId: string, _commentId: string): Promise { + return; +} + +export async function notifyMention(_ticketId: string, _mentionedUserId: string): Promise { + return; +} diff --git a/server/src/services/searchService.ts b/server/src/services/searchService.ts new file mode 100644 index 0000000..19d8296 --- /dev/null +++ b/server/src/services/searchService.ts @@ -0,0 +1,8 @@ +// Stub — Phase 2 replaces this with Postgres FTS (tsvector + plainto_tsquery). +// For now this is a thin wrapper; listTickets already supports a `search` filter. + +import { listTickets, type TicketFilters } from './ticketService'; + +export function searchTickets(query: string, filters: Omit = {}) { + return listTickets({ ...filters, search: query }); +} diff --git a/server/src/services/ticketService.test.ts b/server/src/services/ticketService.test.ts new file mode 100644 index 0000000..1e7de99 --- /dev/null +++ b/server/src/services/ticketService.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { prismaMock } from '../test/setup'; +import { createTicket, updateTicket, closeStale } from './ticketService'; + +const existing = { + id: 'tid', + displayId: 'V100000000', + title: 'Old title', + overview: 'overview', + severity: 3, + status: 'OPEN' as const, + categoryId: 'c1', + typeId: 't1', + itemId: 'i1', + assigneeId: null, + createdById: 'u1', + createdAt: new Date(), + updatedAt: new Date(), + resolvedAt: null, + category: { id: 'c1', name: 'Cat' }, + type: { id: 't1', name: 'Type' }, + item: { id: 'i1', name: 'Item' }, + assignee: null, +}; + +describe('ticketService.createTicket', () => { + it('generates a displayId and writes CREATED audit', async () => { + // First call is generateDisplayId's uniqueness probe — must be null + prismaMock.ticket.findUnique.mockResolvedValueOnce(null); + prismaMock.ticket.create.mockResolvedValue({ ...existing }); + // Second call is the post-create read-with-include + prismaMock.ticket.findUnique.mockResolvedValueOnce({ ...existing }); + + await createTicket( + { + title: 'New', + overview: 'body', + severity: 2, + categoryId: 'c1', + typeId: 't1', + itemId: 'i1', + }, + 'u1', + ); + + expect(prismaMock.ticket.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + title: 'New', + createdById: 'u1', + displayId: expect.stringMatching(/^V\d{9}$/), + }), + }), + ); + expect(prismaMock.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ action: 'CREATED' }) }), + ); + }); +}); + +describe('ticketService.updateTicket', () => { + it('rejects non-admin trying to CLOSE', async () => { + await expect( + updateTicket('tid', { status: 'CLOSED' }, { id: 'u1', role: 'AGENT' }), + ).rejects.toMatchObject({ status: 403 }); + }); + + it('writes STATUS_CHANGED + SEVERITY_CHANGED audits; resolvedAt set on RESOLVED', async () => { + prismaMock.ticket.findFirst.mockResolvedValue(existing); + prismaMock.ticket.update.mockResolvedValue({ + ...existing, + status: 'RESOLVED' as const, + severity: 1, + }); + prismaMock.ticket.findUnique.mockResolvedValue({ ...existing, status: 'RESOLVED' as const }); + + await updateTicket( + 'tid', + { status: 'RESOLVED', severity: 1 }, + { id: 'u2', role: 'AGENT' }, + ); + + expect(prismaMock.ticket.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'RESOLVED', resolvedAt: expect.any(Date) }), + }), + ); + expect(prismaMock.auditLog.createMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.arrayContaining([ + expect.objectContaining({ action: 'STATUS_CHANGED' }), + expect.objectContaining({ action: 'SEVERITY_CHANGED' }), + ]), + }), + ); + }); + + it('clears resolvedAt when moving away from RESOLVED', async () => { + prismaMock.ticket.findFirst.mockResolvedValue({ ...existing, status: 'RESOLVED' as const }); + prismaMock.ticket.update.mockResolvedValue({ ...existing, status: 'IN_PROGRESS' as const }); + prismaMock.ticket.findUnique.mockResolvedValue({ ...existing, status: 'IN_PROGRESS' as const }); + + await updateTicket( + 'tid', + { status: 'IN_PROGRESS' }, + { id: 'u2', role: 'AGENT' }, + ); + + expect(prismaMock.ticket.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ resolvedAt: null }) }), + ); + }); + + it('404s when ticket missing', async () => { + prismaMock.ticket.findFirst.mockResolvedValue(null); + await expect( + updateTicket('missing', { title: 'x' }, { id: 'u1', role: 'ADMIN' }), + ).rejects.toMatchObject({ status: 404 }); + }); +}); + +describe('ticketService.closeStale', () => { + it('closes RESOLVED tickets older than cutoff and returns count', async () => { + prismaMock.ticket.updateMany.mockResolvedValue({ count: 3 }); + const count = await closeStale(14); + expect(count).toBe(3); + expect(prismaMock.ticket.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: 'RESOLVED' }), + data: { status: 'CLOSED' }, + }), + ); + }); +}); diff --git a/server/src/services/ticketService.ts b/server/src/services/ticketService.ts new file mode 100644 index 0000000..9808382 --- /dev/null +++ b/server/src/services/ticketService.ts @@ -0,0 +1,239 @@ +import { Prisma } from '@prisma/client'; +import prisma from '../lib/prisma'; +import { HttpError } from '../lib/httpError'; +import type { + CreateTicketInput, + UpdateTicketInput, +} from '../../../shared/schemas/ticket'; + +export const ticketInclude = { + category: true, + type: true, + item: true, + assignee: { select: { id: true, username: true, displayName: true } }, + createdBy: { select: { id: true, username: true, displayName: true } }, + comments: { + include: { author: { select: { id: true, username: true, displayName: true } } }, + orderBy: { createdAt: 'asc' as const }, + }, +} as const; + +const ticketListInclude = { + category: true, + type: true, + item: true, + assignee: { select: { id: true, username: true, displayName: true } }, + createdBy: { select: { id: true, username: true, displayName: true } }, + _count: { select: { comments: true } }, +} as const; + +const STATUS_LABELS: Record = { + OPEN: 'Open', + IN_PROGRESS: 'In Progress', + RESOLVED: 'Resolved', + CLOSED: 'Closed', +}; + +export type TicketFilters = { + status?: string; + severity?: number; + assigneeId?: string; + categoryId?: string; + typeId?: string; + itemId?: string; + search?: string; +}; + +async function generateDisplayId(): Promise { + while (true) { + const num = Math.floor(Math.random() * 900_000_000) + 100_000_000; + const displayId = `V${num}`; + const exists = await prisma.ticket.findUnique({ where: { displayId } }); + if (!exists) return displayId; + } +} + +export function findByIdOrDisplay(idOrDisplay: string) { + return prisma.ticket.findFirst({ + where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] }, + }); +} + +export async function listTickets(filters: TicketFilters) { + const where: Prisma.TicketWhereInput = {}; + if (filters.status) where.status = filters.status as Prisma.TicketWhereInput['status']; + if (filters.severity) where.severity = filters.severity; + if (filters.assigneeId) where.assigneeId = filters.assigneeId; + if (filters.itemId) where.itemId = filters.itemId; + else if (filters.typeId) where.typeId = filters.typeId; + else if (filters.categoryId) where.categoryId = filters.categoryId; + if (filters.search) { + where.OR = [ + { title: { contains: filters.search, mode: 'insensitive' } }, + { overview: { contains: filters.search, mode: 'insensitive' } }, + { displayId: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + + return prisma.ticket.findMany({ + where, + include: ticketListInclude, + orderBy: [{ severity: 'asc' }, { createdAt: 'desc' }], + }); +} + +export async function getTicket(idOrDisplay: string) { + const ticket = await prisma.ticket.findFirst({ + where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] }, + include: ticketInclude, + }); + if (!ticket) throw new HttpError(404, 'Ticket not found'); + return ticket; +} + +export async function getTicketAudit(idOrDisplay: string) { + const ticket = await findByIdOrDisplay(idOrDisplay); + if (!ticket) throw new HttpError(404, 'Ticket not found'); + + return prisma.auditLog.findMany({ + where: { ticketId: ticket.id }, + include: { user: { select: { id: true, username: true, displayName: true } } }, + orderBy: { createdAt: 'desc' }, + }); +} + +export async function createTicket(data: CreateTicketInput, actorId: string) { + const displayId = await generateDisplayId(); + return prisma.$transaction(async (tx) => { + const created = await tx.ticket.create({ + data: { displayId, ...data, createdById: actorId }, + }); + await tx.auditLog.create({ + data: { ticketId: created.id, userId: actorId, action: 'CREATED' }, + }); + return tx.ticket.findUnique({ where: { id: created.id }, include: ticketInclude }); + }); +} + +export async function updateTicket( + idOrDisplay: string, + data: UpdateTicketInput, + actor: { id: string; role: string }, +) { + if (data.status === 'CLOSED' && actor.role !== 'ADMIN') { + throw new HttpError(403, 'Only admins can close tickets'); + } + + const existing = await prisma.ticket.findFirst({ + where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] }, + include: { + category: true, + type: true, + item: true, + assignee: { select: { displayName: true } }, + }, + }); + if (!existing) throw new HttpError(404, 'Ticket not found'); + + const auditEntries: { action: string; detail?: string }[] = []; + + if (data.status && data.status !== existing.status) { + auditEntries.push({ + action: 'STATUS_CHANGED', + detail: `${STATUS_LABELS[existing.status]} → ${STATUS_LABELS[data.status]}`, + }); + } + + if ('assigneeId' in data && data.assigneeId !== existing.assigneeId) { + const newAssignee = data.assigneeId + ? await prisma.user.findUnique({ + where: { id: data.assigneeId }, + select: { displayName: true }, + }) + : null; + auditEntries.push({ + action: 'ASSIGNEE_CHANGED', + detail: `${existing.assignee?.displayName ?? 'Unassigned'} → ${newAssignee?.displayName ?? 'Unassigned'}`, + }); + } + + if (data.severity && data.severity !== existing.severity) { + auditEntries.push({ + action: 'SEVERITY_CHANGED', + detail: `SEV ${existing.severity} → SEV ${data.severity}`, + }); + } + + const ctiChanged = + (data.categoryId && data.categoryId !== existing.categoryId) || + (data.typeId && data.typeId !== existing.typeId) || + (data.itemId && data.itemId !== existing.itemId); + + if (ctiChanged) { + const [newCat, newType, newItem] = await Promise.all([ + data.categoryId && data.categoryId !== existing.categoryId + ? prisma.category.findUnique({ where: { id: data.categoryId } }) + : Promise.resolve(existing.category), + data.typeId && data.typeId !== existing.typeId + ? prisma.type.findUnique({ where: { id: data.typeId } }) + : Promise.resolve(existing.type), + data.itemId && data.itemId !== existing.itemId + ? prisma.item.findUnique({ where: { id: data.itemId } }) + : Promise.resolve(existing.item), + ]); + auditEntries.push({ + action: 'REROUTED', + detail: `${existing.category.name} › ${existing.type.name} › ${existing.item.name} → ${newCat?.name} › ${newType?.name} › ${newItem?.name}`, + }); + } + + if (data.title && data.title !== existing.title) { + auditEntries.push({ action: 'TITLE_CHANGED', detail: data.title }); + } + + if (data.overview && data.overview !== existing.overview) { + auditEntries.push({ action: 'OVERVIEW_CHANGED' }); + } + + const update: Prisma.TicketUpdateInput = { ...data } as Prisma.TicketUpdateInput; + if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') { + update.resolvedAt = new Date(); + } else if (data.status && data.status !== 'RESOLVED' && existing.status === 'RESOLVED') { + update.resolvedAt = null; + } + + return prisma.$transaction(async (tx) => { + const updated = await tx.ticket.update({ + where: { id: existing.id }, + data: update, + }); + if (auditEntries.length > 0) { + await tx.auditLog.createMany({ + data: auditEntries.map((e) => ({ + ticketId: existing.id, + userId: actor.id, + action: e.action, + detail: e.detail ?? null, + })), + }); + } + return tx.ticket.findUnique({ where: { id: updated.id }, include: ticketInclude }); + }); +} + +export async function deleteTicket(idOrDisplay: string) { + const ticket = await findByIdOrDisplay(idOrDisplay); + if (!ticket) throw new HttpError(404, 'Ticket not found'); + await prisma.ticket.delete({ where: { id: ticket.id } }); +} + +export async function closeStale(olderThanDays = 14): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - olderThanDays); + + const result = await prisma.ticket.updateMany({ + where: { status: 'RESOLVED', resolvedAt: { lte: cutoff } }, + data: { status: 'CLOSED' }, + }); + return result.count; +} diff --git a/server/src/services/userService.test.ts b/server/src/services/userService.test.ts new file mode 100644 index 0000000..3591e28 --- /dev/null +++ b/server/src/services/userService.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { prismaMock } from '../test/setup'; +import { createUser, deleteUser } from './userService'; + +const stubUser = { + id: 'uid', + username: 'x', + email: 'x@x', + displayName: 'X', + role: 'AGENT' as const, + passwordHash: '', + apiKey: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('userService.createUser', () => { + it('hashes the password and omits apiKey for non-SERVICE roles', async () => { + prismaMock.user.create.mockResolvedValue(stubUser); + + await createUser({ + username: 'bob', + email: 'b@x.io', + displayName: 'Bob', + password: 'hunter2!', + role: 'AGENT', + }); + + const call = prismaMock.user.create.mock.calls[0][0] as { data: Record }; + expect(call.data.passwordHash).not.toBe('hunter2!'); + expect(await bcrypt.compare('hunter2!', call.data.passwordHash as string)).toBe(true); + expect(call.data.apiKey).toBeUndefined(); + }); + + it('assigns an apiKey for SERVICE role', async () => { + prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'SERVICE' }); + + await createUser({ + username: 'svc', + email: 's@x.io', + displayName: 'Svc', + role: 'SERVICE', + }); + + const call = prismaMock.user.create.mock.calls[0][0] as { data: Record }; + expect(call.data.apiKey).toMatch(/^sk_[a-f0-9]+$/); + }); +}); + +describe('userService.deleteUser', () => { + it('prevents self-deletion', async () => { + await expect(deleteUser('same', 'same')).rejects.toMatchObject({ status: 400 }); + expect(prismaMock.user.delete).not.toHaveBeenCalled(); + }); + + it('deletes a different user', async () => { + await deleteUser('other', 'me'); + expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'other' } }); + }); +}); diff --git a/server/src/services/userService.ts b/server/src/services/userService.ts new file mode 100644 index 0000000..c1a20da --- /dev/null +++ b/server/src/services/userService.ts @@ -0,0 +1,87 @@ +import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; +import { Prisma } from '@prisma/client'; +import prisma from '../lib/prisma'; +import { HttpError } from '../lib/httpError'; +import type { CreateUserInput, UpdateUserInput } from '../../../shared/schemas/user'; + +const userSelect = { + id: true, + username: true, + displayName: true, + email: true, + role: true, + apiKey: true, + createdAt: true, +} as const; + +const userListSelect = { + id: true, + username: true, + displayName: true, + email: true, + role: true, + createdAt: true, +} as const; + +export function listUsers() { + return prisma.user.findMany({ + select: userListSelect, + orderBy: { displayName: 'asc' }, + }); +} + +export async function getCurrentUser(id: string) { + const user = await prisma.user.findUnique({ + where: { id }, + select: userListSelect, + }); + if (!user) throw new HttpError(404, 'User not found'); + return user; +} + +export async function createUser(data: CreateUserInput) { + const passwordHash = data.password + ? await bcrypt.hash(data.password, 12) + : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12); + + const apiKey = + data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined; + + return prisma.user.create({ + data: { + username: data.username, + email: data.email, + displayName: data.displayName, + passwordHash, + role: data.role, + apiKey, + }, + select: userSelect, + }); +} + +export async function updateUser(id: string, data: UpdateUserInput) { + const update: Prisma.UserUpdateInput = {}; + if (data.displayName) update.displayName = data.displayName; + if (data.email) update.email = data.email; + if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12); + if (data.role) { + update.role = data.role; + if (data.role === 'SERVICE' && !update.apiKey) { + update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`; + } + } + if (data.regenerateApiKey) { + update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`; + } + + return prisma.user.update({ where: { id }, data: update, select: userSelect }); +} + +export async function deleteUser(id: string, actorId: string) { + if (id === actorId) { + throw new HttpError(400, 'Cannot delete your own account'); + } + await prisma.user.delete({ where: { id } }); +} diff --git a/server/src/test/setup.ts b/server/src/test/setup.ts new file mode 100644 index 0000000..e2ac29e --- /dev/null +++ b/server/src/test/setup.ts @@ -0,0 +1,30 @@ +import { beforeEach, vi } from 'vitest'; +import { mockDeep, mockReset, DeepMockProxy } from 'vitest-mock-extended'; +import type { PrismaClient } from '@prisma/client'; + +vi.mock('../lib/prisma', () => ({ + __esModule: true, + default: mockDeep(), +})); + +// Provide a JWT secret so authService can sign tokens in tests +process.env.JWT_SECRET ??= 'test-secret'; + +import prisma from '../lib/prisma'; + +export const prismaMock = prisma as unknown as DeepMockProxy; + +beforeEach(() => { + mockReset(prismaMock); + + // $transaction([promises]) — resolve each promise + prismaMock.$transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === 'function') { + return (arg as (tx: unknown) => unknown)(prismaMock); + } + if (Array.isArray(arg)) { + return Promise.all(arg); + } + return arg; + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index fa8ee32..c9ed5fe 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,13 +4,13 @@ "module": "commonjs", "lib": ["ES2020"], "outDir": "./dist", - "rootDir": "./src", + "rootDir": "..", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "include": ["src/**/*", "../shared/**/*"], + "exclude": ["node_modules", "dist", "../client", "../node_modules"] } diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 0000000..70d764f --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.ts'], + setupFiles: ['src/test/setup.ts'], + }, +}); diff --git a/shared/schemas/auth.ts b/shared/schemas/auth.ts new file mode 100644 index 0000000..3b2e733 --- /dev/null +++ b/shared/schemas/auth.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +export type LoginInput = z.infer; diff --git a/shared/schemas/comment.ts b/shared/schemas/comment.ts new file mode 100644 index 0000000..c77efcb --- /dev/null +++ b/shared/schemas/comment.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const commentSchema = z.object({ + body: z.string().min(1), +}); + +export type CommentInput = z.infer; diff --git a/shared/schemas/cti.ts b/shared/schemas/cti.ts new file mode 100644 index 0000000..77d2d83 --- /dev/null +++ b/shared/schemas/cti.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const ctiNameSchema = z.object({ + name: z.string().min(1).max(100), +}); + +export const createTypeSchema = z.object({ + name: z.string().min(1).max(100), + categoryId: z.string().min(1), +}); + +export const createItemSchema = z.object({ + name: z.string().min(1).max(100), + typeId: z.string().min(1), +}); + +export type CtiNameInput = z.infer; +export type CreateTypeInput = z.infer; +export type CreateItemInput = z.infer; diff --git a/shared/schemas/enums.ts b/shared/schemas/enums.ts new file mode 100644 index 0000000..7a164d7 --- /dev/null +++ b/shared/schemas/enums.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const ROLES = ['ADMIN', 'AGENT', 'USER', 'SERVICE'] as const; +export const TICKET_STATUSES = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const; + +export const roleSchema = z.enum(ROLES); +export const ticketStatusSchema = z.enum(TICKET_STATUSES); + +export type Role = (typeof ROLES)[number]; +export type TicketStatus = (typeof TICKET_STATUSES)[number]; + +export const SEVERITY_MIN = 1; +export const SEVERITY_MAX = 5; +export const severitySchema = z.number().int().min(SEVERITY_MIN).max(SEVERITY_MAX); diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts new file mode 100644 index 0000000..1692171 --- /dev/null +++ b/shared/schemas/index.ts @@ -0,0 +1,6 @@ +export * from './enums'; +export * from './auth'; +export * from './ticket'; +export * from './comment'; +export * from './user'; +export * from './cti'; diff --git a/shared/schemas/ticket.ts b/shared/schemas/ticket.ts new file mode 100644 index 0000000..69ccacd --- /dev/null +++ b/shared/schemas/ticket.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { severitySchema, ticketStatusSchema } from './enums'; + +export const createTicketSchema = z.object({ + title: z.string().min(1).max(255), + overview: z.string().min(1), + severity: severitySchema, + categoryId: z.string().min(1), + typeId: z.string().min(1), + itemId: z.string().min(1), + assigneeId: z.string().optional(), +}); + +export const updateTicketSchema = z.object({ + title: z.string().min(1).max(255).optional(), + overview: z.string().min(1).optional(), + severity: severitySchema.optional(), + status: ticketStatusSchema.optional(), + categoryId: z.string().min(1).optional(), + typeId: z.string().min(1).optional(), + itemId: z.string().min(1).optional(), + assigneeId: z.string().nullable().optional(), +}); + +export type CreateTicketInput = z.infer; +export type UpdateTicketInput = z.infer; diff --git a/shared/schemas/user.ts b/shared/schemas/user.ts new file mode 100644 index 0000000..a267f3d --- /dev/null +++ b/shared/schemas/user.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { roleSchema } from './enums'; + +export const createUserSchema = z.object({ + username: z.string().min(1).max(50), + email: z.string().email(), + displayName: z.string().min(1).max(100), + password: z.string().min(8).optional(), + role: roleSchema.default('AGENT'), +}); + +export const updateUserSchema = z.object({ + displayName: z.string().min(1).max(100).optional(), + email: z.string().email().optional(), + password: z.string().min(8).optional(), + role: roleSchema.optional(), + regenerateApiKey: z.boolean().optional(), +}); + +export type CreateUserInput = z.infer; +export type UpdateUserInput = z.infer; diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..791bf0d --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,78 @@ +import type { Role, TicketStatus } from './schemas/enums'; + +export type { Role, TicketStatus }; + +export interface UserSummary { + id: string; + username: string; + displayName: string; +} + +export interface User extends UserSummary { + email: string; + role: Role; + apiKey?: string | null; + createdAt?: string; +} + +export interface Category { + id: string; + name: string; +} + +export interface CTIType { + id: string; + name: string; + categoryId: string; + category?: Category; +} + +export interface Item { + id: string; + name: string; + typeId: string; + type?: CTIType & { category?: Category }; +} + +export interface Comment { + id: string; + body: string; + ticketId: string; + authorId: string; + author: UserSummary; + createdAt: string; +} + +export interface AuditLog { + id: string; + ticketId: string; + userId: string; + action: string; + detail: string | null; + createdAt: string; + user: UserSummary; +} + +export interface Ticket { + id: string; + displayId: string; + title: string; + overview: string; + severity: number; + status: TicketStatus; + categoryId: string; + typeId: string; + itemId: string; + assigneeId: string | null; + createdById: string; + resolvedAt: string | null; + createdAt: string; + updatedAt: string; + category: Category; + type: CTIType; + item: Item; + assignee: UserSummary | null; + createdBy: UserSummary; + comments?: Comment[]; + _count?: { comments: number }; +}