From a7fbb2c466ed0b3c864a43943dd792879fbd75a7 Mon Sep 17 00:00:00 2001 From: Peter Maquiran Date: Fri, 17 Apr 2026 23:42:24 +0100 Subject: [PATCH] add moodules --- package.json | 16 +- pnpm-lock.yaml | 1216 ++++++++++++++++- prisma.config.ts | 12 + prisma/schema.prisma | 143 ++ src/app.module.ts | 26 +- src/config/configuration.ts | 16 + src/infrastructure/storage/minio.service.ts | 56 + src/infrastructure/storage/storage.module.ts | 10 + .../storage/thumbor-url.service.ts | 25 + src/main.ts | 14 +- src/module/articles/articles.controller.ts | 86 ++ src/module/articles/articles.module.ts | 17 + src/module/articles/articles.service.ts | 320 +++++ src/module/articles/dto/attach-image.dto.ts | 14 + src/module/articles/dto/create-article.dto.ts | 53 + .../articles/dto/list-articles-query.dto.ts | 36 + src/module/articles/dto/update-article.dto.ts | 55 + src/module/auth/auth.module.ts | 4 +- src/module/bookmarks/bookmarks.controller.ts | 44 + src/module/bookmarks/bookmarks.module.ts | 17 + src/module/bookmarks/bookmarks.service.ts | 58 + .../categories/categories.controller.ts | 55 + src/module/categories/categories.module.ts | 17 + src/module/categories/categories.service.ts | 109 ++ .../categories/dto/create-category.dto.ts | 17 + .../categories/dto/update-category.dto.ts | 18 + src/module/comments/comments.controller.ts | 42 + src/module/comments/comments.module.ts | 17 + src/module/comments/comments.service.ts | 80 ++ src/module/comments/dto/create-comment.dto.ts | 12 + src/module/images/images.controller.ts | 27 + src/module/images/images.module.ts | 17 + src/module/images/images.service.ts | 32 + src/module/profile/profile.controller.ts | 37 +- src/module/profile/profile.module.ts | 11 +- src/module/tags/dto/create-tag.dto.ts | 13 + src/module/tags/dto/update-tag.dto.ts | 14 + src/module/tags/tags.controller.ts | 50 + src/module/tags/tags.module.ts | 17 + src/module/tags/tags.service.ts | 44 + src/module/users/dto/update-me.dto.ts | 13 + src/module/users/user-provisioning.guard.ts | 23 + src/module/users/users.controller.ts | 24 + src/module/users/users.module.ts | 17 + src/module/users/users.service.ts | 64 + .../decorators/current-db-user.decorator.ts | 9 + .../decorators/current-user.decorator.ts | 18 + src/shared/decorators/roles.decorator.ts | 6 + src/shared/dto/pagination-query.dto.ts | 17 + src/shared/guards/roles.guard.ts | 30 + src/shared/prisma/prisma.module.ts | 9 + src/shared/prisma/prisma.service.ts | 30 + src/shared/utils/slug.ts | 10 + src/types/express.d.ts | 11 + 54 files changed, 3074 insertions(+), 74 deletions(-) create mode 100644 prisma.config.ts create mode 100644 prisma/schema.prisma create mode 100644 src/config/configuration.ts create mode 100644 src/infrastructure/storage/minio.service.ts create mode 100644 src/infrastructure/storage/storage.module.ts create mode 100644 src/infrastructure/storage/thumbor-url.service.ts create mode 100644 src/module/articles/articles.controller.ts create mode 100644 src/module/articles/articles.module.ts create mode 100644 src/module/articles/articles.service.ts create mode 100644 src/module/articles/dto/attach-image.dto.ts create mode 100644 src/module/articles/dto/create-article.dto.ts create mode 100644 src/module/articles/dto/list-articles-query.dto.ts create mode 100644 src/module/articles/dto/update-article.dto.ts create mode 100644 src/module/bookmarks/bookmarks.controller.ts create mode 100644 src/module/bookmarks/bookmarks.module.ts create mode 100644 src/module/bookmarks/bookmarks.service.ts create mode 100644 src/module/categories/categories.controller.ts create mode 100644 src/module/categories/categories.module.ts create mode 100644 src/module/categories/categories.service.ts create mode 100644 src/module/categories/dto/create-category.dto.ts create mode 100644 src/module/categories/dto/update-category.dto.ts create mode 100644 src/module/comments/comments.controller.ts create mode 100644 src/module/comments/comments.module.ts create mode 100644 src/module/comments/comments.service.ts create mode 100644 src/module/comments/dto/create-comment.dto.ts create mode 100644 src/module/images/images.controller.ts create mode 100644 src/module/images/images.module.ts create mode 100644 src/module/images/images.service.ts create mode 100644 src/module/tags/dto/create-tag.dto.ts create mode 100644 src/module/tags/dto/update-tag.dto.ts create mode 100644 src/module/tags/tags.controller.ts create mode 100644 src/module/tags/tags.module.ts create mode 100644 src/module/tags/tags.service.ts create mode 100644 src/module/users/dto/update-me.dto.ts create mode 100644 src/module/users/user-provisioning.guard.ts create mode 100644 src/module/users/users.controller.ts create mode 100644 src/module/users/users.module.ts create mode 100644 src/module/users/users.service.ts create mode 100644 src/shared/decorators/current-db-user.decorator.ts create mode 100644 src/shared/decorators/current-user.decorator.ts create mode 100644 src/shared/decorators/roles.decorator.ts create mode 100644 src/shared/dto/pagination-query.dto.ts create mode 100644 src/shared/guards/roles.guard.ts create mode 100644 src/shared/prisma/prisma.module.ts create mode 100644 src/shared/prisma/prisma.service.ts create mode 100644 src/shared/utils/slug.ts create mode 100644 src/types/express.d.ts diff --git a/package.json b/package.json index 1b78137..fef174c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "private": true, "license": "UNLICENSED", "scripts": { - "build": "nest build", + "prisma:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:push": "prisma db push", + "build": "prisma generate && nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", @@ -21,12 +24,19 @@ }, "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.1", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@prisma/adapter-pg": "^7.7.0", + "@prisma/client": "^7.7.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", "jwks-rsa": "^4.0.1", + "minio": "^8.0.6", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -38,14 +48,18 @@ "@nestjs/testing": "^11.0.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^1.4.12", "@types/node": "^24.0.0", + "@types/pg": "^8.15.5", "@types/supertest": "^7.0.0", + "dotenv": "^16.5.0", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", "globals": "^17.0.0", "jest": "^30.0.0", "prettier": "^3.4.2", + "prisma": "^7.7.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fa4b7f..21c064f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,25 +10,46 @@ importers: dependencies: '@nestjs/common': specifier: ^11.0.1 - version: 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.0 + version: 4.0.4(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.1 - version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': specifier: ^11.0.5 - version: 11.0.5(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + version: 11.0.5(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^11.0.1 - version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@prisma/adapter-pg': + specifier: ^7.7.0 + version: 7.7.0 + '@prisma/client': + specifier: ^7.7.0 + version: 7.7.0(prisma@7.7.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3))(typescript@5.9.3) + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.2 + version: 0.14.4 jwks-rsa: specifier: ^4.0.1 version: 4.0.1 + minio: + specifier: ^8.0.6 + version: 8.0.7 passport: specifier: ^0.7.0 version: 0.7.0 passport-jwt: specifier: ^4.0.1 version: 4.0.1 + pg: + specifier: ^8.20.0 + version: 8.20.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -50,28 +71,37 @@ importers: version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.1 - version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19) + version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19) '@types/express': specifier: ^5.0.0 version: 5.0.6 '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/multer': + specifier: ^1.4.12 + version: 1.4.13 '@types/node': specifier: ^24.0.0 version: 24.12.2 + '@types/pg': + specifier: ^8.15.5 + version: 8.20.0 '@types/supertest': specifier: ^7.0.0 version: 7.2.0 + dotenv: + specifier: ^16.5.0 + version: 16.6.1 eslint: specifier: ^9.18.0 - version: 9.39.4 + version: 9.39.4(jiti@2.6.1) eslint-config-prettier: specifier: ^10.0.1 - version: 10.1.8(eslint@9.39.4) + version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.2.2 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.3) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) globals: specifier: ^17.0.0 version: 17.5.0 @@ -81,6 +111,9 @@ importers: prettier: specifier: ^3.4.2 version: 3.8.3 + prisma: + specifier: ^7.7.0 + version: 7.7.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -104,7 +137,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.20.0 - version: 8.58.2(eslint@9.39.4)(typescript@5.9.3) + version: 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) packages: @@ -302,6 +335,20 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@electric-sql/pglite-socket@0.1.1': + resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1': + resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': + resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -349,6 +396,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -624,6 +677,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -657,6 +713,12 @@ packages: class-validator: optional: true + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/core@11.1.19': resolution: {integrity: sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw==} engines: {node: '>= 20'} @@ -713,6 +775,9 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@nuxt/opencollective@0.4.1': resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} @@ -729,6 +794,143 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@prisma/adapter-pg@7.7.0': + resolution: {integrity: sha512-q33Ta8sKbgzEpAy0lx45tAq//yMv0qcb+8nj+TCA3P4wiAY+OBFEFk/NDkZncAfHaNJeGo5WJpJdpbL+ijYx8g==} + + '@prisma/client-runtime-utils@7.7.0': + resolution: {integrity: sha512-BLyd0UpFYOtyJFTHm7jS9vesHW7P83abibodQMiIofqjBKzDHQ1VAsQkdfvXyYDkPlONPfOTz7/rv3x/+CQqvQ==} + + '@prisma/client@7.7.0': + resolution: {integrity: sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.7.0': + resolution: {integrity: sha512-hmPI3tKLO2aP0Y5vugbjcnA9qqlfJndiT6ds4tw28U5hNHLWg+mHJEWAhjsSPgxjtmxhJ/EDIeIlyh+3Us0OPg==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.7.0': + resolution: {integrity: sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==} + + '@prisma/dev@0.24.3': + resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} + + '@prisma/driver-adapter-utils@7.7.0': + resolution: {integrity: sha512-gZXREeu6mOk7zXfGFJgh86p7Vhj0sXNKp+4Cg1tWYo7V2dfncP2qxS2BiTmbIIha8xPqItkl0WSw38RuSq1HoQ==} + + '@prisma/engines-version@7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711': + resolution: {integrity: sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==} + + '@prisma/engines@7.7.0': + resolution: {integrity: sha512-7fmcbT7HHXBq/b+3h/dO1JI3fd8l8q7erf7xP7pRprh58hmSSnG8mg9K3yjW3h9WaHWUwngVFpSxxxivaitQ2w==} + + '@prisma/fetch-engine@7.7.0': + resolution: {integrity: sha512-TfyzveBQoK4xALzsTpVhB/0KG1N8zOK0ap+RnBMkzGUu3f98fnQ4QtXa2wlKPhsO2X8a3N5ugFQgcKNoHGmDfw==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.7.0': + resolution: {integrity: sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.2': + resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} + engines: {bun: '>=1.3.6', node: '>=22.0.0'} + + '@prisma/studio-core@0.27.3': + resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@sinclair/typebox@0.34.49': resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} @@ -738,6 +940,9 @@ packages: '@sinonjs/fake-timers@15.3.2': resolution: {integrity: sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -823,15 +1028,24 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@1.4.13': + resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} + '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -847,6 +1061,9 @@ packages: '@types/supertest@7.2.0': resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1179,9 +1396,16 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + babel-jest@30.3.0: resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1222,9 +1446,15 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-result@2.8.2: + resolution: {integrity: sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + block-stream2@2.1.0: + resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -1243,6 +1473,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-or-node@2.1.1: + resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1255,6 +1488,10 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -1272,6 +1509,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1306,6 +1551,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1318,9 +1567,21 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.4: + resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1384,6 +1645,9 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1430,6 +1694,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1439,6 +1706,10 @@ packages: supports-color: optional: true + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -1450,6 +1721,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -1457,14 +1732,24 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1476,6 +1761,18 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1489,6 +1786,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + electron-to-chromium@1.5.340: resolution: {integrity: sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==} @@ -1502,6 +1802,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1510,6 +1814,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1630,6 +1938,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -1650,6 +1961,13 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1668,6 +1986,13 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-builder@1.1.5: + resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + + fast-xml-parser@5.7.0: + resolution: {integrity: sha512-MTcrUoRQ1GSQ9iG3QJzBGquYYYeA7piZaJoIWbPFGbRn6Jj6z7xgoAyi4DrZX4y2ZIQQBF59gc/zmvvejjgoFQ==} + hasBin: true + fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -1692,6 +2017,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -1756,6 +2085,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1772,6 +2104,9 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1780,6 +2115,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1815,6 +2154,12 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + handlebars@4.7.9: resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} @@ -1836,6 +2181,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono@4.12.14: + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} + engines: {node: '>=16.9.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1843,6 +2192,9 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -1886,6 +2238,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -1916,6 +2272,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -2086,6 +2445,10 @@ packages: node-notifier: optional: true + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} @@ -2156,6 +2519,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.41: + resolution: {integrity: sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==} + limiter@1.1.5: resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} @@ -2215,6 +2581,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2228,6 +2597,10 @@ packages: lru-memoizer@3.0.0: resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -2311,6 +2684,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minio@8.0.7: + resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==} + engines: {node: ^16 || ^18 || >=20} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -2326,6 +2703,14 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2347,6 +2732,9 @@ packages: node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -2361,6 +2749,11 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2369,6 +2762,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -2438,6 +2834,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2461,9 +2861,49 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2483,10 +2923,37 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2504,6 +2971,22 @@ packages: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prisma@7.7.0: + resolution: {integrity: sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2512,6 +2995,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} @@ -2519,6 +3005,10 @@ packages: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2527,9 +3017,21 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -2541,6 +3043,9 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2565,6 +3070,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2581,6 +3090,13 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} @@ -2602,6 +3118,9 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -2662,9 +3181,21 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -2673,10 +3204,23 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -2716,6 +3260,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -2773,6 +3320,13 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -2946,6 +3500,18 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3009,6 +3575,18 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3036,6 +3614,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + snapshots: '@angular-devkit/core@19.2.24(chokidar@4.0.3)': @@ -3269,6 +3850,16 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -3285,9 +3876,9 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -3331,6 +3922,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@hono/node-server@1.19.11(hono@4.12.14)': + dependencies: + hono: 4.12.14 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -3707,6 +4302,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kurkle/color@0.3.4': {} + '@lukeed/csprng@1.1.0': {} '@napi-rs/wasm-runtime@0.2.12': @@ -3743,7 +4340,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.4 iterare: 1.2.1 @@ -3752,12 +4349,23 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/config@4.0.4(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.4.1 + dotenv-expand: 12.0.3 + lodash: 4.18.1 + rxjs: 7.8.2 + + '@nestjs/core@11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -3767,17 +4375,17 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) - '@nestjs/passport@11.0.5(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + '@nestjs/passport@11.0.5(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': dependencies: - '@nestjs/common': 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) passport: 0.7.0 - '@nestjs/platform-express@11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)': + '@nestjs/platform-express@11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)': dependencies: - '@nestjs/common': 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.1.1 @@ -3799,16 +4407,18 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19)': + '@nestjs/testing@11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19)': dependencies: - '@nestjs/common': 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) '@noble/hashes@1.8.0': {} + '@nodable/entities@2.1.0': {} + '@nuxt/opencollective@0.4.1': dependencies: consola: 3.4.2 @@ -3822,6 +4432,159 @@ snapshots: '@pkgr/core@0.2.9': {} + '@prisma/adapter-pg@7.7.0': + dependencies: + '@prisma/driver-adapter-utils': 7.7.0 + '@types/pg': 8.20.0 + pg: 8.20.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.7.0': {} + + '@prisma/client@7.7.0(prisma@7.7.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.7.0 + optionalDependencies: + prisma: 7.7.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.7.0': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.7.0': {} + + '@prisma/dev@0.24.3(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) + '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) + '@hono/node-server': 1.19.11(hono@4.12.14) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.2 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.12.14 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.7.0': + dependencies: + '@prisma/debug': 7.7.0 + + '@prisma/engines-version@7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711': {} + + '@prisma/engines@7.7.0': + dependencies: + '@prisma/debug': 7.7.0 + '@prisma/engines-version': 7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711 + '@prisma/fetch-engine': 7.7.0 + '@prisma/get-platform': 7.7.0 + + '@prisma/fetch-engine@7.7.0': + dependencies: + '@prisma/debug': 7.7.0 + '@prisma/engines-version': 7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711 + '@prisma/get-platform': 7.7.0 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.7.0': + dependencies: + '@prisma/debug': 7.7.0 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.2': + dependencies: + ajv: 8.18.0 + better-result: 2.8.2 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.27.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/react': 19.2.14 + chart.js: 4.5.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - '@types/react-dom' + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-toggle@1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@sinclair/typebox@0.34.49': {} '@sinonjs/commons@3.0.1': @@ -3832,6 +4595,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.1.0': {} + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -3939,14 +4704,28 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@1.4.13': + dependencies: + '@types/express': 5.0.6 + '@types/node@24.12.2': dependencies: undici-types: 7.16.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 24.12.2 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + '@types/send@1.2.1': dependencies: '@types/node': 24.12.2 @@ -3970,21 +4749,23 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/validator@13.15.10': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.58.2 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -3992,14 +4773,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4022,13 +4803,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.58.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.58.2 '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -4051,13 +4832,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4297,8 +5078,12 @@ snapshots: asap@2.0.6: {} + async@3.2.6: {} + asynckit@0.4.0: {} + aws-ssl-profiles@1.1.2: {} + babel-jest@30.3.0(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 @@ -4359,12 +5144,18 @@ snapshots: baseline-browser-mapping@2.10.19: {} + better-result@2.8.2: {} + bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 + block-stream2@2.1.0: + dependencies: + readable-stream: 3.6.2 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -4396,6 +5187,8 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-or-node@2.1.1: {} + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.19 @@ -4412,6 +5205,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -4427,6 +5222,21 @@ snapshots: bytes@3.1.2: {} + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4454,6 +5264,10 @@ snapshots: chardet@2.1.1: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -4462,8 +5276,22 @@ snapshots: ci-info@4.4.0: {} + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + cjs-module-lexer@2.2.0: {} + class-transformer@0.5.1: {} + + class-validator@0.14.4: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.41 + validator: 13.15.35 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -4520,6 +5348,8 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + confbox@0.2.4: {} + consola@3.4.2: {} content-disposition@1.1.0: {} @@ -4556,24 +5386,36 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decode-uri-component@0.2.2: {} + dedent@1.7.2: {} deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} defaults@1.0.4: dependencies: clone: 1.0.4 + defu@6.1.7: {} + delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} + destr@2.0.5: {} + detect-newline@3.1.0: {} dezalgo@1.0.4: @@ -4583,6 +5425,14 @@ snapshots: diff@4.0.4: {} + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.4.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4597,6 +5447,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.340: {} emittery@0.13.1: {} @@ -4605,6 +5460,8 @@ snapshots: emoji-regex@9.2.2: {} + empathic@2.0.0: {} + encodeurl@2.0.0: {} enhanced-resolve@5.20.1: @@ -4612,6 +5469,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + env-paths@3.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4641,19 +5500,19 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.4): + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.3): + eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) prettier: 3.8.3 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.8(eslint@9.39.4) + eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-scope@5.1.1: dependencies: @@ -4671,9 +5530,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4: + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 @@ -4707,6 +5566,8 @@ snapshots: minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -4734,6 +5595,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} execa@5.1.1: @@ -4792,6 +5655,12 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.0.8: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -4804,6 +5673,17 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-builder@1.1.5: + dependencies: + path-expression-matcher: 1.5.0 + + fast-xml-parser@5.7.0: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.1.5 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 + fb-watchman@2.0.2: dependencies: bser: 2.1.1 @@ -4829,6 +5709,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + filter-obj@1.1.0: {} + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -4912,6 +5794,10 @@ snapshots: function-bind@1.1.2: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -4931,6 +5817,8 @@ snapshots: get-package-type@0.1.0: {} + get-port-please@3.2.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -4938,6 +5826,15 @@ snapshots: get-stream@6.0.1: {} + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -4976,6 +5873,10 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.12: {} + + graphmatch@1.1.1: {} + handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -4997,6 +5898,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.12.14: {} + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -5007,6 +5910,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-status-codes@2.3.0: {} + human-signals@2.1.0: {} iconv-lite@0.7.2: @@ -5040,6 +5945,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} + is-arrayish@0.2.1: {} is-extglob@2.1.1: {} @@ -5058,6 +5965,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-stream@2.0.1: {} is-unicode-supported@0.1.0: {} @@ -5420,6 +6329,8 @@ snapshots: - supports-color - ts-node + jiti@2.6.1: {} + jose@6.2.2: {} js-tokens@4.0.0: {} @@ -5500,6 +6411,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.41: {} + limiter@1.1.5: {} lines-and-columns@1.2.4: {} @@ -5543,6 +6456,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + lru-cache@10.4.3: {} lru-cache@11.3.5: {} @@ -5556,6 +6471,8 @@ snapshots: lodash.clonedeep: 4.5.0 lru-cache: 11.3.5 + lru.min@1.1.4: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5621,6 +6538,22 @@ snapshots: minimist@1.2.8: {} + minio@8.0.7: + dependencies: + async: 3.2.6 + block-stream2: 2.1.0 + browser-or-node: 2.1.1 + buffer-crc32: 1.0.0 + eventemitter3: 5.0.4 + fast-xml-parser: 5.7.0 + ipaddr.js: 2.3.0 + lodash: 4.18.1 + mime-types: 2.1.35 + query-string: 7.1.3 + stream-json: 1.9.1 + through2: 4.0.2 + xml2js: 0.6.2 + minipass@7.1.3: {} ms@2.1.3: {} @@ -5634,6 +6567,22 @@ snapshots: mute-stream@2.0.0: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -5648,6 +6597,8 @@ snapshots: dependencies: lodash: 4.18.1 + node-fetch-native@1.6.7: {} + node-int64@0.4.0: {} node-releases@2.0.37: {} @@ -5658,10 +6609,18 @@ snapshots: dependencies: path-key: 3.1.1 + nypm@0.6.5: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.1.1 + object-assign@4.1.1: {} object-inspect@1.13.4: {} + ohash@2.0.11: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -5743,6 +6702,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -5761,8 +6722,47 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + pause@0.0.1: {} + perfect-debounce@1.0.0: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5775,8 +6775,28 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + pluralize@8.0.0: {} + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -5791,6 +6811,29 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@7.7.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.7.0 + '@prisma/dev': 0.24.3(typescript@5.9.3) + '@prisma/engines': 7.7.0 + '@prisma/studio-core': 0.27.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -5798,12 +6841,21 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + pure-rand@7.0.1: {} qs@6.15.1: dependencies: side-channel: 1.1.0 + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + range-parser@1.2.1: {} raw-body@3.0.2: @@ -5813,8 +6865,20 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + react-is@18.3.1: {} + react@19.2.5: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -5825,6 +6889,8 @@ snapshots: reflect-metadata@0.2.2: {} + remeda@2.33.4: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -5842,6 +6908,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + retry@0.12.0: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -5864,6 +6932,10 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.6.0: {} + + scheduler@0.27.0: {} + schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 @@ -5897,6 +6969,8 @@ snapshots: transitivePeerDependencies: - supports-color + seq-queue@0.0.5: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -5964,16 +7038,32 @@ snapshots: source-map@0.7.6: {} + split-on-first@1.1.0: {} + + split2@4.2.0: {} + sprintf-js@1.0.3: {} + sqlstring@2.3.3: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 statuses@2.0.2: {} + std-env@3.10.0: {} + + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + streamsearch@1.1.0: {} + strict-uri-encode@2.0.0: {} + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -6011,6 +7101,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.2.3: {} + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -6074,6 +7166,12 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + through2@4.0.2: + dependencies: + readable-stream: 3.6.2 + + tinyexec@1.1.1: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -6183,13 +7281,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.58.2(eslint@9.39.4)(typescript@5.9.3): + typescript-eslint@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.58.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6257,6 +7355,12 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + validator@13.15.35: {} + vary@1.1.2: {} walker@1.0.8: @@ -6341,6 +7445,15 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + xml2js@0.6.2: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -6362,3 +7475,8 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.1 diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..921e7b4 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,12 @@ +import 'dotenv/config'; +import { defineConfig, env } from 'prisma/config'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: env('DATABASE_URL'), + }, +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..6ce7e44 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,143 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" +} + +enum UserRole { + ADMIN + EDITOR + AUTHOR + READER +} + +enum ArticleStatus { + DRAFT + PUBLISHED + ARCHIVED +} + +model User { + id String @id @default(uuid()) + keycloakId String @unique + email String @unique + displayName String? + avatarKey String? + role UserRole @default(READER) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + articles Article[] + comments Comment[] + bookmarks Bookmark[] +} + +model Category { + id String @id @default(uuid()) + name String + slug String @unique + parentId String? + parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull) + children Category[] @relation("CategoryTree") + articles ArticleCategory[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([parentId]) +} + +model Tag { + id String @id @default(uuid()) + name String + slug String @unique + articles ArticleTag[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Article { + id String @id @default(uuid()) + title String + slug String @unique + content String @db.Text + excerpt String? @db.Text + status ArticleStatus @default(DRAFT) + publishedAt DateTime? + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + categories ArticleCategory[] + tags ArticleTag[] + images ArticleImage[] + comments Comment[] + bookmarks Bookmark[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, publishedAt]) + @@index([authorId]) +} + +model ArticleCategory { + articleId String + categoryId String + article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + + @@id([articleId, categoryId]) +} + +model ArticleTag { + articleId String + tagId String + article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([articleId, tagId]) +} + +model Image { + id String @id @default(uuid()) + fileKey String @unique + articles ArticleImage[] + createdAt DateTime @default(now()) +} + +model ArticleImage { + articleId String + imageId String + sortOrder Int @default(0) + article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) + image Image @relation(fields: [imageId], references: [id], onDelete: Cascade) + + @@id([articleId, imageId]) + @@index([articleId, sortOrder]) +} + +model Comment { + id String @id @default(uuid()) + content String @db.Text + articleId String + userId String + parentId String? + article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + parent Comment? @relation("CommentThread", fields: [parentId], references: [id], onDelete: Cascade) + replies Comment[] @relation("CommentThread") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([articleId]) + @@index([parentId]) +} + +model Bookmark { + userId String + articleId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@id([userId, articleId]) +} diff --git a/src/app.module.ts b/src/app.module.ts index a62491c..dfd5df4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,35 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import configuration from './config/configuration'; +import { StorageModule } from './infrastructure/storage/storage.module'; +import { ArticlesModule } from './module/articles/articles.module'; import { AuthModule } from './module/auth/auth.module'; +import { BookmarksModule } from './module/bookmarks/bookmarks.module'; +import { CategoriesModule } from './module/categories/categories.module'; +import { CommentsModule } from './module/comments/comments.module'; +import { ImagesModule } from './module/images/images.module'; import { ProfileModule } from './module/profile/profile.module'; +import { TagsModule } from './module/tags/tags.module'; +import { UsersModule } from './module/users/users.module'; +import { PrismaModule } from './shared/prisma/prisma.module'; @Module({ - imports: [AuthModule, ProfileModule], + imports: [ + ConfigModule.forRoot({ isGlobal: true, load: [configuration] }), + PrismaModule, + StorageModule, + AuthModule, + UsersModule, + ProfileModule, + CategoriesModule, + TagsModule, + ArticlesModule, + ImagesModule, + CommentsModule, + BookmarksModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/src/config/configuration.ts b/src/config/configuration.ts new file mode 100644 index 0000000..5c4902d --- /dev/null +++ b/src/config/configuration.ts @@ -0,0 +1,16 @@ +export default () => ({ + port: parseInt(process.env.PORT ?? '3001', 10), + databaseUrl: process.env.DATABASE_URL, + minio: { + endpoint: process.env.MINIO_ENDPOINT ?? 'localhost', + port: parseInt(process.env.MINIO_PORT ?? '9000', 10), + useSsl: process.env.MINIO_USE_SSL === 'true', + accessKey: process.env.MINIO_ACCESS_KEY ?? '', + secretKey: process.env.MINIO_SECRET_KEY ?? '', + bucket: process.env.MINIO_BUCKET ?? 'tvone', + publicBaseUrl: process.env.MINIO_PUBLIC_BASE_URL ?? '', + }, + thumbor: { + publicBaseUrl: process.env.THUMBOR_PUBLIC_URL ?? '', + }, +}); diff --git a/src/infrastructure/storage/minio.service.ts b/src/infrastructure/storage/minio.service.ts new file mode 100644 index 0000000..f6e3fb5 --- /dev/null +++ b/src/infrastructure/storage/minio.service.ts @@ -0,0 +1,56 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Client } from 'minio'; +import { randomUUID } from 'crypto'; + +@Injectable() +export class MinioService implements OnModuleInit { + private client: Client | null = null; + private bucket: string; + + constructor(private readonly config: ConfigService) { + this.bucket = this.config.get('minio.bucket', 'tvone'); + } + + async onModuleInit() { + const accessKey = this.config.get('minio.accessKey', ''); + const secretKey = this.config.get('minio.secretKey', ''); + if (!accessKey || !secretKey) { + return; + } + this.client = new Client({ + endPoint: this.config.get('minio.endpoint', 'localhost'), + port: this.config.get('minio.port', 9000), + useSSL: this.config.get('minio.useSsl', false), + accessKey, + secretKey, + }); + const exists = await this.client.bucketExists(this.bucket).catch(() => false); + if (!exists) { + await this.client.makeBucket(this.bucket, ''); + } + } + + isConfigured(): boolean { + return this.client !== null; + } + + async putObject( + objectName: string, + buffer: Buffer, + contentType: string, + ): Promise { + if (!this.client) { + throw new Error('MinIO is not configured (MINIO_ACCESS_KEY / MINIO_SECRET_KEY)'); + } + await this.client.putObject(this.bucket, objectName, buffer, buffer.length, { + 'Content-Type': contentType, + }); + return objectName; + } + + buildOriginalsKey(prefix: string, originalName: string): string { + const safe = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); + return `${prefix}/${randomUUID()}-${safe}`; + } +} diff --git a/src/infrastructure/storage/storage.module.ts b/src/infrastructure/storage/storage.module.ts new file mode 100644 index 0000000..9b3d382 --- /dev/null +++ b/src/infrastructure/storage/storage.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { MinioService } from './minio.service'; +import { ThumborUrlService } from './thumbor-url.service'; + +@Global() +@Module({ + providers: [MinioService, ThumborUrlService], + exports: [MinioService, ThumborUrlService], +}) +export class StorageModule {} diff --git a/src/infrastructure/storage/thumbor-url.service.ts b/src/infrastructure/storage/thumbor-url.service.ts new file mode 100644 index 0000000..296bd29 --- /dev/null +++ b/src/infrastructure/storage/thumbor-url.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** + * Builds Thumbor-style URLs for the CDN/Varnish layer in front of Thumbor. + * Example path segment: /800x0/smart/originals/articles/abc.jpg + */ +@Injectable() +export class ThumborUrlService { + constructor(private readonly config: ConfigService) {} + + imageUrl(fileKey: string, options?: { width?: number; height?: number; smart?: boolean }) { + const base = this.config.get('thumbor.publicBaseUrl', ''); + if (!base) { + return { path: `/${fileKey}`, full: null as string | null }; + } + const w = options?.width ?? 0; + const h = options?.height ?? 0; + const smart = options?.smart !== false ? 'smart' : 'fit-in'; + const dims = `${w}x${h}`; + const path = `/${dims}/${smart}/${fileKey}`; + const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; + return { path, full: `${normalizedBase}${path}` }; + } +} diff --git a/src/main.ts b/src/main.ts index e80a752..00d0189 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,20 @@ +import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); app.enableCors({ - origin: ["http://localhost:3000"], // 👈 array is safer - methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization"], + origin: ['http://localhost:3000'], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, }); await app.listen(process.env.PORT ?? 3001); diff --git a/src/module/articles/articles.controller.ts b/src/module/articles/articles.controller.ts new file mode 100644 index 0000000..978f0a1 --- /dev/null +++ b/src/module/articles/articles.controller.ts @@ -0,0 +1,86 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { User, UserRole } from '@prisma/client'; +import { CurrentDbUser } from '../../shared/decorators/current-db-user.decorator'; +import { Roles } from '../../shared/decorators/roles.decorator'; +import { RolesGuard } from '../../shared/guards/roles.guard'; +import { UserProvisioningGuard } from '../users/user-provisioning.guard'; +import { ArticlesService } from './articles.service'; +import { AttachImageDto } from './dto/attach-image.dto'; +import { CreateArticleDto } from './dto/create-article.dto'; +import { ListArticlesQueryDto, ManageArticlesQueryDto } from './dto/list-articles-query.dto'; +import { UpdateArticleDto } from './dto/update-article.dto'; + +@Controller('articles') +export class ArticlesController { + constructor(private readonly articlesService: ArticlesService) {} + + @Get() + listPublished(@Query() query: ListArticlesQueryDto) { + return this.articlesService.listPublished(query); + } + + @Get('by-slug/:slug') + findPublishedBySlug(@Param('slug') slug: string) { + return this.articlesService.findPublishedBySlug(slug); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard) + @Get('manage') + listManage(@CurrentDbUser() user: User, @Query() query: ManageArticlesQueryDto) { + return this.articlesService.listManage(user, query); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard) + @Get('by-id/:id') + findById(@Param('id', ParseUUIDPipe) id: string, @CurrentDbUser() user: User) { + return this.articlesService.findByIdForUser(id, user); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR) + @Post() + create(@CurrentDbUser() user: User, @Body() dto: CreateArticleDto) { + return this.articlesService.create(user, dto); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR) + @Patch(':id') + update( + @CurrentDbUser() user: User, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateArticleDto, + ) { + return this.articlesService.update(user, id, dto); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR) + @Delete(':id') + remove(@CurrentDbUser() user: User, @Param('id', ParseUUIDPipe) id: string) { + return this.articlesService.remove(user, id); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR) + @Post(':id/images') + attachImage( + @CurrentDbUser() user: User, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AttachImageDto, + ) { + return this.articlesService.attachImage(user, id, dto); + } +} diff --git a/src/module/articles/articles.module.ts b/src/module/articles/articles.module.ts new file mode 100644 index 0000000..5bf382d --- /dev/null +++ b/src/module/articles/articles.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { AuthModule } from '../auth/auth.module'; +import { UsersModule } from '../users/users.module'; +import { ArticlesController } from './articles.controller'; +import { ArticlesService } from './articles.service'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'keycloak' }), + AuthModule, + UsersModule, + ], + controllers: [ArticlesController], + providers: [ArticlesService], +}) +export class ArticlesModule {} diff --git a/src/module/articles/articles.service.ts b/src/module/articles/articles.service.ts new file mode 100644 index 0000000..d2f851a --- /dev/null +++ b/src/module/articles/articles.service.ts @@ -0,0 +1,320 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Article, ArticleStatus, Prisma, User, UserRole } from '@prisma/client'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { slugify } from '../../shared/utils/slug'; +import { AttachImageDto } from './dto/attach-image.dto'; +import { CreateArticleDto } from './dto/create-article.dto'; +import { ListArticlesQueryDto, ManageArticlesQueryDto } from './dto/list-articles-query.dto'; +import { UpdateArticleDto } from './dto/update-article.dto'; + +const articleInclude = { + author: { + select: { id: true, displayName: true, email: true, avatarKey: true }, + }, + categories: { include: { category: true } }, + tags: { include: { tag: true } }, + images: { include: { image: true }, orderBy: { sortOrder: 'asc' as const } }, +} satisfies Prisma.ArticleInclude; + +function canManageAllArticles(user: User) { + return user.role === UserRole.ADMIN || user.role === UserRole.EDITOR; +} + +function canAuthorArticles(user: User) { + return ( + user.role === UserRole.ADMIN || + user.role === UserRole.EDITOR || + user.role === UserRole.AUTHOR + ); +} + +function canEditArticle(user: User, article: Article) { + if (canManageAllArticles(user)) return true; + return user.role === UserRole.AUTHOR && article.authorId === user.id; +} + +@Injectable() +export class ArticlesService { + constructor(private readonly prisma: PrismaService) {} + + async listPublished(query: ListArticlesQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const where: Prisma.ArticleWhereInput = { + status: ArticleStatus.PUBLISHED, + ...(query.search + ? { + OR: [ + { title: { contains: query.search, mode: 'insensitive' } }, + { excerpt: { contains: query.search, mode: 'insensitive' } }, + ], + } + : {}), + ...(query.categoryId + ? { + categories: { some: { categoryId: query.categoryId } }, + } + : {}), + ...(query.tagId + ? { + tags: { some: { tagId: query.tagId } }, + } + : {}), + }; + const [items, total] = await Promise.all([ + this.prisma.article.findMany({ + where, + include: articleInclude, + orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.article.count({ where }), + ]); + return { items, total, page, limit }; + } + + async listManage(user: User, query: ManageArticlesQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const where: Prisma.ArticleWhereInput = { + ...(canManageAllArticles(user) ? {} : { authorId: user.id }), + ...(query.status ? { status: query.status } : {}), + ...(query.search + ? { + OR: [ + { title: { contains: query.search, mode: 'insensitive' } }, + { excerpt: { contains: query.search, mode: 'insensitive' } }, + ], + } + : {}), + ...(query.categoryId + ? { + categories: { some: { categoryId: query.categoryId } }, + } + : {}), + ...(query.tagId + ? { + tags: { some: { tagId: query.tagId } }, + } + : {}), + }; + const [items, total] = await Promise.all([ + this.prisma.article.findMany({ + where, + include: articleInclude, + orderBy: [{ updatedAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.article.count({ where }), + ]); + return { items, total, page, limit }; + } + + async findPublishedBySlug(slug: string) { + const article = await this.prisma.article.findFirst({ + where: { slug, status: ArticleStatus.PUBLISHED }, + include: articleInclude, + }); + if (!article) throw new NotFoundException('Article not found'); + return article; + } + + async findByIdForUser(id: string, user: User) { + const article = await this.prisma.article.findUnique({ + where: { id }, + include: articleInclude, + }); + if (!article) throw new NotFoundException('Article not found'); + if (article.status === ArticleStatus.PUBLISHED) return article; + if (!canEditArticle(user, article)) { + throw new ForbiddenException('You cannot view this article'); + } + return article; + } + + async create(user: User, dto: CreateArticleDto) { + if (!canAuthorArticles(user)) { + throw new ForbiddenException('Cannot create articles'); + } + const slug = await this.uniqueSlug(dto.slug?.trim() || slugify(dto.title)); + const status = dto.status ?? ArticleStatus.DRAFT; + const publishedAt = + status === ArticleStatus.PUBLISHED ? new Date() : null; + + return this.prisma.$transaction(async (tx) => { + const created = await tx.article.create({ + data: { + title: dto.title, + slug, + content: dto.content, + excerpt: dto.excerpt ?? null, + status, + publishedAt, + authorId: user.id, + categories: dto.categoryIds?.length + ? { + create: dto.categoryIds.map((categoryId) => ({ + category: { connect: { id: categoryId } }, + })), + } + : undefined, + tags: dto.tagIds?.length + ? { + create: dto.tagIds.map((tagId) => ({ + tag: { connect: { id: tagId } }, + })), + } + : undefined, + }, + include: articleInclude, + }); + + if (dto.imageIds?.length) { + await this.replaceArticleImagesTx(tx, created.id, dto.imageIds); + } + + return tx.article.findUniqueOrThrow({ + where: { id: created.id }, + include: articleInclude, + }); + }); + } + + async update(user: User, id: string, dto: UpdateArticleDto) { + const existing = await this.prisma.article.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Article not found'); + if (!canEditArticle(user, existing)) { + throw new ForbiddenException('Cannot edit this article'); + } + + let slug = existing.slug; + if (dto.slug?.trim()) { + slug = await this.uniqueSlug(dto.slug.trim(), id); + } else if (dto.title && dto.title !== existing.title) { + slug = await this.uniqueSlug(slugify(dto.title), id); + } + + let publishedAt = existing.publishedAt; + let status = dto.status ?? existing.status; + if (dto.status === ArticleStatus.PUBLISHED && existing.status !== ArticleStatus.PUBLISHED) { + publishedAt = new Date(); + status = ArticleStatus.PUBLISHED; + } + if (dto.status === ArticleStatus.DRAFT) { + publishedAt = null; + status = ArticleStatus.DRAFT; + } + if (dto.status === ArticleStatus.ARCHIVED) { + status = ArticleStatus.ARCHIVED; + } + + return this.prisma.$transaction(async (tx) => { + await tx.article.update({ + where: { id }, + data: { + title: dto.title ?? undefined, + slug, + content: dto.content ?? undefined, + excerpt: dto.excerpt === undefined ? undefined : dto.excerpt, + status, + publishedAt, + }, + }); + + if (dto.categoryIds) { + await tx.articleCategory.deleteMany({ where: { articleId: id } }); + if (dto.categoryIds.length) { + await tx.articleCategory.createMany({ + data: dto.categoryIds.map((categoryId) => ({ articleId: id, categoryId })), + }); + } + } + + if (dto.tagIds) { + await tx.articleTag.deleteMany({ where: { articleId: id } }); + if (dto.tagIds.length) { + await tx.articleTag.createMany({ + data: dto.tagIds.map((tagId) => ({ articleId: id, tagId })), + }); + } + } + + if (dto.imageIds) { + await this.replaceArticleImagesTx(tx, id, dto.imageIds); + } + + return tx.article.findUniqueOrThrow({ + where: { id }, + include: articleInclude, + }); + }); + } + + async remove(user: User, id: string) { + const existing = await this.prisma.article.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Article not found'); + if (!canEditArticle(user, existing)) { + throw new ForbiddenException('Cannot delete this article'); + } + await this.prisma.article.delete({ where: { id } }); + return { deleted: true }; + } + + async attachImage(user: User, articleId: string, dto: AttachImageDto) { + const article = await this.prisma.article.findUnique({ where: { id: articleId } }); + if (!article) throw new NotFoundException('Article not found'); + if (!canEditArticle(user, article)) { + throw new ForbiddenException('Cannot edit this article'); + } + const image = await this.prisma.image.findUnique({ where: { id: dto.imageId } }); + if (!image) throw new NotFoundException('Image not found'); + + await this.prisma.articleImage.upsert({ + where: { articleId_imageId: { articleId, imageId: dto.imageId } }, + create: { + articleId, + imageId: dto.imageId, + sortOrder: dto.sortOrder ?? 0, + }, + update: { sortOrder: dto.sortOrder ?? 0 }, + }); + + return this.prisma.article.findUniqueOrThrow({ + where: { id: articleId }, + include: articleInclude, + }); + } + + private async replaceArticleImagesTx( + tx: Prisma.TransactionClient, + articleId: string, + imageIds: string[], + ) { + await tx.articleImage.deleteMany({ where: { articleId } }); + if (!imageIds.length) return; + const rows = imageIds.map((imageId, index) => ({ + articleId, + imageId, + sortOrder: index, + })); + await tx.articleImage.createMany({ data: rows }); + } + + private async uniqueSlug(base: string, excludeArticleId?: string) { + let candidate = base; + let n = 1; + while (true) { + const found = await this.prisma.article.findUnique({ where: { slug: candidate } }); + if (!found || found.id === excludeArticleId) return candidate; + n += 1; + candidate = `${base}-${n}`; + } + } +} diff --git a/src/module/articles/dto/attach-image.dto.ts b/src/module/articles/dto/attach-image.dto.ts new file mode 100644 index 0000000..b7ed6a3 --- /dev/null +++ b/src/module/articles/dto/attach-image.dto.ts @@ -0,0 +1,14 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator'; + +export class AttachImageDto { + @IsUUID() + imageId: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(10_000) + sortOrder?: number; +} diff --git a/src/module/articles/dto/create-article.dto.ts b/src/module/articles/dto/create-article.dto.ts new file mode 100644 index 0000000..6ff6873 --- /dev/null +++ b/src/module/articles/dto/create-article.dto.ts @@ -0,0 +1,53 @@ +import { ArticleStatus } from '@prisma/client'; +import { + ArrayUnique, + IsArray, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateArticleDto { + @IsString() + @MinLength(1) + @MaxLength(200) + title: string; + + @IsOptional() + @IsString() + @MaxLength(220) + slug?: string; + + @IsString() + @MinLength(1) + content: string; + + @IsOptional() + @IsString() + @MaxLength(500) + excerpt?: string; + + @IsOptional() + @IsEnum(ArticleStatus) + status?: ArticleStatus; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + categoryIds?: string[]; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + tagIds?: string[]; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + imageIds?: string[]; +} diff --git a/src/module/articles/dto/list-articles-query.dto.ts b/src/module/articles/dto/list-articles-query.dto.ts new file mode 100644 index 0000000..409185e --- /dev/null +++ b/src/module/articles/dto/list-articles-query.dto.ts @@ -0,0 +1,36 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { ArticleStatus } from '@prisma/client'; + +export class ListArticlesQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsUUID() + tagId?: string; +} + +export class ManageArticlesQueryDto extends ListArticlesQueryDto { + @IsOptional() + @IsEnum(ArticleStatus) + status?: ArticleStatus; +} diff --git a/src/module/articles/dto/update-article.dto.ts b/src/module/articles/dto/update-article.dto.ts new file mode 100644 index 0000000..78e3778 --- /dev/null +++ b/src/module/articles/dto/update-article.dto.ts @@ -0,0 +1,55 @@ +import { ArticleStatus } from '@prisma/client'; +import { + ArrayUnique, + IsArray, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class UpdateArticleDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + @MaxLength(220) + slug?: string; + + @IsOptional() + @IsString() + @MinLength(1) + content?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + excerpt?: string; + + @IsOptional() + @IsEnum(ArticleStatus) + status?: ArticleStatus; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + categoryIds?: string[]; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + tagIds?: string[]; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + imageIds?: string[]; +} diff --git a/src/module/auth/auth.module.ts b/src/module/auth/auth.module.ts index 87b26aa..3a72cf4 100644 --- a/src/module/auth/auth.module.ts +++ b/src/module/auth/auth.module.ts @@ -5,7 +5,7 @@ import { KeycloakStrategy } from "./keycloak.strategy"; @Module({ imports: [PassportModule], - providers: [KeycloakStrategy], // 👈 THIS IS THE FIX - exports: [PassportModule], + providers: [KeycloakStrategy], + exports: [PassportModule, KeycloakStrategy], }) export class AuthModule {} \ No newline at end of file diff --git a/src/module/bookmarks/bookmarks.controller.ts b/src/module/bookmarks/bookmarks.controller.ts new file mode 100644 index 0000000..4b1d4ca --- /dev/null +++ b/src/module/bookmarks/bookmarks.controller.ts @@ -0,0 +1,44 @@ +import { + Controller, + DefaultValuePipe, + Delete, + Get, + Param, + ParseIntPipe, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { CurrentDbUser } from '../../shared/decorators/current-db-user.decorator'; +import { User } from '@prisma/client'; +import { UserProvisioningGuard } from '../users/user-provisioning.guard'; +import { BookmarksService } from './bookmarks.service'; + +@Controller('bookmarks') +export class BookmarksController { + constructor(private readonly bookmarksService: BookmarksService) {} + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard) + @Get('me') + listMine( + @CurrentDbUser() user: User, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + ) { + return this.bookmarksService.listMine(user, page, limit); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard) + @Post(':articleId') + add(@CurrentDbUser() user: User, @Param('articleId', ParseUUIDPipe) articleId: string) { + return this.bookmarksService.add(user, articleId); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard) + @Delete(':articleId') + remove(@CurrentDbUser() user: User, @Param('articleId', ParseUUIDPipe) articleId: string) { + return this.bookmarksService.remove(user, articleId); + } +} diff --git a/src/module/bookmarks/bookmarks.module.ts b/src/module/bookmarks/bookmarks.module.ts new file mode 100644 index 0000000..766e34a --- /dev/null +++ b/src/module/bookmarks/bookmarks.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { AuthModule } from '../auth/auth.module'; +import { UsersModule } from '../users/users.module'; +import { BookmarksController } from './bookmarks.controller'; +import { BookmarksService } from './bookmarks.service'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'keycloak' }), + AuthModule, + UsersModule, + ], + controllers: [BookmarksController], + providers: [BookmarksService], +}) +export class BookmarksModule {} diff --git a/src/module/bookmarks/bookmarks.service.ts b/src/module/bookmarks/bookmarks.service.ts new file mode 100644 index 0000000..0bb3d2d --- /dev/null +++ b/src/module/bookmarks/bookmarks.service.ts @@ -0,0 +1,58 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ArticleStatus, User } from '@prisma/client'; +import { PrismaService } from '../../shared/prisma/prisma.service'; + +const bookmarkArticleInclude = { + author: { + select: { id: true, displayName: true, avatarKey: true }, + }, + categories: { include: { category: true } }, + tags: { include: { tag: true } }, +} as const; + +@Injectable() +export class BookmarksService { + constructor(private readonly prisma: PrismaService) {} + + async listMine(user: User, page = 1, limit = 20) { + const take = Math.min(Math.max(limit, 1), 100); + const skip = (Math.max(page, 1) - 1) * take; + const where = { userId: user.id }; + const [rows, total] = await Promise.all([ + this.prisma.bookmark.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take, + include: { article: { include: bookmarkArticleInclude } }, + }), + this.prisma.bookmark.count({ where }), + ]); + return { items: rows, total, page, limit: take }; + } + + async add(user: User, articleId: string) { + const article = await this.prisma.article.findFirst({ + where: { id: articleId, status: ArticleStatus.PUBLISHED }, + }); + if (!article) throw new NotFoundException('Article not found'); + return this.prisma.bookmark.upsert({ + where: { + userId_articleId: { userId: user.id, articleId }, + }, + create: { userId: user.id, articleId }, + update: {}, + include: { article: { include: bookmarkArticleInclude } }, + }); + } + + async remove(user: User, articleId: string) { + const res = await this.prisma.bookmark.deleteMany({ + where: { userId: user.id, articleId }, + }); + if (res.count === 0) { + throw new NotFoundException('Bookmark not found'); + } + return { deleted: true }; + } +} diff --git a/src/module/categories/categories.controller.ts b/src/module/categories/categories.controller.ts new file mode 100644 index 0000000..f30d15b --- /dev/null +++ b/src/module/categories/categories.controller.ts @@ -0,0 +1,55 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { UserRole } from '@prisma/client'; +import { Roles } from '../../shared/decorators/roles.decorator'; +import { RolesGuard } from '../../shared/guards/roles.guard'; +import { UserProvisioningGuard } from '../users/user-provisioning.guard'; +import { CategoriesService } from './categories.service'; +import { CreateCategoryDto } from './dto/create-category.dto'; +import { UpdateCategoryDto } from './dto/update-category.dto'; + +@Controller('categories') +export class CategoriesController { + constructor(private readonly categoriesService: CategoriesService) {} + + @Get() + tree() { + return this.categoriesService.tree(); + } + + @Get('flat') + flat() { + return this.categoriesService.findAllFlat(); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR) + @Post() + create(@Body() dto: CreateCategoryDto) { + return this.categoriesService.create(dto); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR) + @Patch(':id') + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCategoryDto) { + return this.categoriesService.update(id, dto); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR) + @Delete(':id') + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.categoriesService.remove(id); + } +} diff --git a/src/module/categories/categories.module.ts b/src/module/categories/categories.module.ts new file mode 100644 index 0000000..6ef1cf4 --- /dev/null +++ b/src/module/categories/categories.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { AuthModule } from '../auth/auth.module'; +import { UsersModule } from '../users/users.module'; +import { CategoriesController } from './categories.controller'; +import { CategoriesService } from './categories.service'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'keycloak' }), + AuthModule, + UsersModule, + ], + controllers: [CategoriesController], + providers: [CategoriesService], +}) +export class CategoriesModule {} diff --git a/src/module/categories/categories.service.ts b/src/module/categories/categories.service.ts new file mode 100644 index 0000000..9381b20 --- /dev/null +++ b/src/module/categories/categories.service.ts @@ -0,0 +1,109 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { slugify } from '../../shared/utils/slug'; +import { CreateCategoryDto } from './dto/create-category.dto'; +import { UpdateCategoryDto } from './dto/update-category.dto'; + +@Injectable() +export class CategoriesService { + constructor(private readonly prisma: PrismaService) {} + + async tree() { + const all = await this.prisma.category.findMany({ + orderBy: [{ parentId: 'asc' }, { name: 'asc' }], + }); + const byParent = new Map(); + for (const c of all) { + const key = c.parentId; + if (!byParent.has(key)) byParent.set(key, []); + byParent.get(key)!.push(c); + } + const build = (parentId: string | null): unknown[] => { + const nodes = byParent.get(parentId) ?? []; + return nodes.map((n) => ({ + ...n, + children: build(n.id), + })); + }; + return build(null); + } + + async findAllFlat() { + return this.prisma.category.findMany({ orderBy: { name: 'asc' } }); + } + + async create(dto: CreateCategoryDto) { + const slug = dto.slug?.trim() || slugify(dto.name); + if (dto.parentId) { + const parent = await this.prisma.category.findUnique({ + where: { id: dto.parentId }, + }); + if (!parent) throw new NotFoundException('Parent category not found'); + } + return this.prisma.category.create({ + data: { + name: dto.name, + slug, + parentId: dto.parentId ?? null, + }, + }); + } + + async update(id: string, dto: UpdateCategoryDto) { + await this.ensureExists(id); + if (dto.parentId === id) { + throw new BadRequestException('Category cannot be its own parent'); + } + if (dto.parentId) { + const parent = await this.prisma.category.findUnique({ + where: { id: dto.parentId }, + }); + if (!parent) throw new NotFoundException('Parent category not found'); + if (await this.isDescendant(dto.parentId, id)) { + throw new BadRequestException('Cannot set parent to a descendant'); + } + } + return this.prisma.category.update({ + where: { id }, + data: { + name: dto.name, + slug: dto.slug?.trim() ?? undefined, + parentId: dto.parentId === undefined ? undefined : dto.parentId, + }, + }); + } + + private async isDescendant(ancestorId: string, nodeId: string): Promise { + let current = await this.prisma.category.findUnique({ + where: { id: nodeId }, + select: { parentId: true }, + }); + const visited = new Set(); + while (current?.parentId) { + if (current.parentId === ancestorId) return true; + if (visited.has(current.parentId)) break; + visited.add(current.parentId); + current = await this.prisma.category.findUnique({ + where: { id: current.parentId }, + select: { parentId: true }, + }); + } + return false; + } + + async remove(id: string) { + await this.ensureExists(id); + const children = await this.prisma.category.count({ where: { parentId: id } }); + if (children > 0) { + throw new BadRequestException('Remove or reassign child categories first'); + } + await this.prisma.category.delete({ where: { id } }); + return { deleted: true }; + } + + private async ensureExists(id: string) { + const row = await this.prisma.category.findUnique({ where: { id } }); + if (!row) throw new NotFoundException('Category not found'); + return row; + } +} diff --git a/src/module/categories/dto/create-category.dto.ts b/src/module/categories/dto/create-category.dto.ts new file mode 100644 index 0000000..f68d513 --- /dev/null +++ b/src/module/categories/dto/create-category.dto.ts @@ -0,0 +1,17 @@ +import { IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class CreateCategoryDto { + @IsString() + @MinLength(1) + @MaxLength(120) + name: string; + + @IsOptional() + @IsString() + @MaxLength(140) + slug?: string; + + @IsOptional() + @IsUUID() + parentId?: string | null; +} diff --git a/src/module/categories/dto/update-category.dto.ts b/src/module/categories/dto/update-category.dto.ts new file mode 100644 index 0000000..0455e07 --- /dev/null +++ b/src/module/categories/dto/update-category.dto.ts @@ -0,0 +1,18 @@ +import { IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class UpdateCategoryDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(120) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(140) + slug?: string; + + @IsOptional() + @IsUUID() + parentId?: string | null; +} diff --git a/src/module/comments/comments.controller.ts b/src/module/comments/comments.controller.ts new file mode 100644 index 0000000..260ed81 --- /dev/null +++ b/src/module/comments/comments.controller.ts @@ -0,0 +1,42 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { CurrentDbUser } from '../../shared/decorators/current-db-user.decorator'; +import { User } from '@prisma/client'; +import { UserProvisioningGuard } from '../users/user-provisioning.guard'; +import { CommentsService } from './comments.service'; +import { CreateCommentDto } from './dto/create-comment.dto'; + +@Controller('comments') +export class CommentsController { + constructor(private readonly commentsService: CommentsService) {} + + @Get('article/:articleId') + listForArticle(@Param('articleId', ParseUUIDPipe) articleId: string) { + return this.commentsService.listForArticle(articleId); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard) + @Post('article/:articleId') + create( + @Param('articleId', ParseUUIDPipe) articleId: string, + @CurrentDbUser() user: User, + @Body() dto: CreateCommentDto, + ) { + return this.commentsService.create(articleId, user, dto); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard) + @Delete(':id') + remove(@Param('id', ParseUUIDPipe) id: string, @CurrentDbUser() user: User) { + return this.commentsService.remove(id, user); + } +} diff --git a/src/module/comments/comments.module.ts b/src/module/comments/comments.module.ts new file mode 100644 index 0000000..4a5e956 --- /dev/null +++ b/src/module/comments/comments.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { AuthModule } from '../auth/auth.module'; +import { UsersModule } from '../users/users.module'; +import { CommentsController } from './comments.controller'; +import { CommentsService } from './comments.service'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'keycloak' }), + AuthModule, + UsersModule, + ], + controllers: [CommentsController], + providers: [CommentsService], +}) +export class CommentsModule {} diff --git a/src/module/comments/comments.service.ts b/src/module/comments/comments.service.ts new file mode 100644 index 0000000..175f764 --- /dev/null +++ b/src/module/comments/comments.service.ts @@ -0,0 +1,80 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ArticleStatus, User, UserRole } from '@prisma/client'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { CreateCommentDto } from './dto/create-comment.dto'; + +@Injectable() +export class CommentsService { + constructor(private readonly prisma: PrismaService) {} + + async listForArticle(articleId: string) { + await this.ensurePublishedArticle(articleId); + const rows = await this.prisma.comment.findMany({ + where: { articleId }, + include: { + user: { select: { id: true, displayName: true, avatarKey: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + const byParent = new Map(); + for (const row of rows) { + const key = row.parentId; + if (!byParent.has(key)) byParent.set(key, []); + byParent.get(key)!.push(row); + } + const build = (parentId: string | null): unknown[] => + (byParent.get(parentId) ?? []).map((c) => ({ + ...c, + replies: build(c.id), + })); + return build(null); + } + + async create(articleId: string, user: User, dto: CreateCommentDto) { + await this.ensurePublishedArticle(articleId); + if (dto.parentId) { + const parent = await this.prisma.comment.findUnique({ + where: { id: dto.parentId }, + }); + if (!parent || parent.articleId !== articleId) { + throw new BadRequestException('Invalid parent comment'); + } + } + return this.prisma.comment.create({ + data: { + content: dto.content, + articleId, + userId: user.id, + parentId: dto.parentId ?? null, + }, + include: { + user: { select: { id: true, displayName: true, avatarKey: true } }, + }, + }); + } + + async remove(commentId: string, user: User) { + const comment = await this.prisma.comment.findUnique({ where: { id: commentId } }); + if (!comment) throw new NotFoundException('Comment not found'); + const isOwner = comment.userId === user.id; + const isStaff = user.role === UserRole.ADMIN || user.role === UserRole.EDITOR; + if (!isOwner && !isStaff) { + throw new ForbiddenException('Cannot delete this comment'); + } + await this.prisma.comment.delete({ where: { id: commentId } }); + return { deleted: true }; + } + + private async ensurePublishedArticle(articleId: string) { + const article = await this.prisma.article.findUnique({ where: { id: articleId } }); + if (!article || article.status !== ArticleStatus.PUBLISHED) { + throw new NotFoundException('Article not found'); + } + return article; + } +} diff --git a/src/module/comments/dto/create-comment.dto.ts b/src/module/comments/dto/create-comment.dto.ts new file mode 100644 index 0000000..6136dd0 --- /dev/null +++ b/src/module/comments/dto/create-comment.dto.ts @@ -0,0 +1,12 @@ +import { IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class CreateCommentDto { + @IsString() + @MinLength(1) + @MaxLength(10_000) + content: string; + + @IsOptional() + @IsUUID() + parentId?: string; +} diff --git a/src/module/images/images.controller.ts b/src/module/images/images.controller.ts new file mode 100644 index 0000000..c9261ce --- /dev/null +++ b/src/module/images/images.controller.ts @@ -0,0 +1,27 @@ +import { + Controller, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AuthGuard } from '@nestjs/passport'; +import { UserRole } from '@prisma/client'; +import { Roles } from '../../shared/decorators/roles.decorator'; +import { RolesGuard } from '../../shared/guards/roles.guard'; +import { UserProvisioningGuard } from '../users/user-provisioning.guard'; +import { ImagesService } from './images.service'; + +@Controller('images') +export class ImagesController { + constructor(private readonly imagesService: ImagesService) {} + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR) + @Post('upload') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 15 * 1024 * 1024 } })) + upload(@UploadedFile() file: Express.Multer.File) { + return this.imagesService.uploadOriginal(file); + } +} diff --git a/src/module/images/images.module.ts b/src/module/images/images.module.ts new file mode 100644 index 0000000..1df5cf9 --- /dev/null +++ b/src/module/images/images.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { AuthModule } from '../auth/auth.module'; +import { UsersModule } from '../users/users.module'; +import { ImagesController } from './images.controller'; +import { ImagesService } from './images.service'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'keycloak' }), + AuthModule, + UsersModule, + ], + controllers: [ImagesController], + providers: [ImagesService], +}) +export class ImagesModule {} diff --git a/src/module/images/images.service.ts b/src/module/images/images.service.ts new file mode 100644 index 0000000..dc9fce7 --- /dev/null +++ b/src/module/images/images.service.ts @@ -0,0 +1,32 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { MinioService } from '../../infrastructure/storage/minio.service'; +import { ThumborUrlService } from '../../infrastructure/storage/thumbor-url.service'; + +@Injectable() +export class ImagesService { + constructor( + private readonly prisma: PrismaService, + private readonly minio: MinioService, + private readonly thumbor: ThumborUrlService, + ) {} + + async uploadOriginal(file: Express.Multer.File | undefined) { + if (!file?.buffer?.length) { + throw new BadRequestException('File is required'); + } + if (!this.minio.isConfigured()) { + throw new BadRequestException( + 'MinIO is not configured. Set MINIO_ACCESS_KEY and MINIO_SECRET_KEY.', + ); + } + const contentType = file.mimetype || 'application/octet-stream'; + const key = this.minio.buildOriginalsKey('originals/articles', file.originalname); + await this.minio.putObject(key, file.buffer, contentType); + const image = await this.prisma.image.create({ + data: { fileKey: key }, + }); + const preview = this.thumbor.imageUrl(key, { width: 800, height: 0, smart: true }); + return { ...image, urls: preview }; + } +} diff --git a/src/module/profile/profile.controller.ts b/src/module/profile/profile.controller.ts index 0b5a700..f0aa6bf 100644 --- a/src/module/profile/profile.controller.ts +++ b/src/module/profile/profile.controller.ts @@ -1,24 +1,35 @@ import { KeycloakAuthGuard } from '../auth/keycloak.guard'; -import { ProfileService } from './profile.service'; import { Controller, Get, UseGuards, Request } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; +import { User } from '@prisma/client'; +import { Request } from 'express'; +import { UserProvisioningGuard } from '../users/user-provisioning.guard'; +type ProfileRequest = Request & { + user: { + email?: string; + name?: string; + picture?: string; + email_verified?: boolean; + roles: string[]; + }; + dbUser?: User; +}; @Controller('profile') export class ProfileController { - constructor(private readonly profileService: ProfileService) {} - - @UseGuards(AuthGuard('keycloak')) + @UseGuards(KeycloakAuthGuard, UserProvisioningGuard) @Get() - getProfile(@Request() req) { - // The 'user' object here is exactly what you returned from validate() - + getProfile(@Request() req: ProfileRequest) { + const kc = req.user; return { - email: req.user.email, - name: req.user.name, - picture: req.user.picture, - email_verified: req.user.email_verified, - roles: req.user.roles, + keycloak: { + email: kc.email, + name: kc.name, + picture: kc.picture, + email_verified: kc.email_verified, + roles: kc.roles, + }, + user: req.dbUser, }; } } diff --git a/src/module/profile/profile.module.ts b/src/module/profile/profile.module.ts index b90bb83..9863c77 100644 --- a/src/module/profile/profile.module.ts +++ b/src/module/profile/profile.module.ts @@ -1,16 +1,17 @@ import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; +import { AuthModule } from '../auth/auth.module'; +import { UsersModule } from '../users/users.module'; import { ProfileController } from './profile.controller'; import { ProfileService } from './profile.service'; -import { KeycloakStrategy } from '../auth/keycloak.strategy'; @Module({ imports: [ - // Registers the 'keycloak' strategy as a default if needed PassportModule.register({ defaultStrategy: 'keycloak' }), + AuthModule, + UsersModule, ], controllers: [ProfileController], - providers: [ProfileService, KeycloakStrategy], - exports: [], + providers: [ProfileService], }) -export class ProfileModule {} \ No newline at end of file +export class ProfileModule {} diff --git a/src/module/tags/dto/create-tag.dto.ts b/src/module/tags/dto/create-tag.dto.ts new file mode 100644 index 0000000..93ce160 --- /dev/null +++ b/src/module/tags/dto/create-tag.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateTagDto { + @IsString() + @MinLength(1) + @MaxLength(80) + name: string; + + @IsOptional() + @IsString() + @MaxLength(100) + slug?: string; +} diff --git a/src/module/tags/dto/update-tag.dto.ts b/src/module/tags/dto/update-tag.dto.ts new file mode 100644 index 0000000..d313746 --- /dev/null +++ b/src/module/tags/dto/update-tag.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateTagDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(80) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + slug?: string; +} diff --git a/src/module/tags/tags.controller.ts b/src/module/tags/tags.controller.ts new file mode 100644 index 0000000..8691efa --- /dev/null +++ b/src/module/tags/tags.controller.ts @@ -0,0 +1,50 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { UserRole } from '@prisma/client'; +import { Roles } from '../../shared/decorators/roles.decorator'; +import { RolesGuard } from '../../shared/guards/roles.guard'; +import { UserProvisioningGuard } from '../users/user-provisioning.guard'; +import { CreateTagDto } from './dto/create-tag.dto'; +import { UpdateTagDto } from './dto/update-tag.dto'; +import { TagsService } from './tags.service'; + +@Controller('tags') +export class TagsController { + constructor(private readonly tagsService: TagsService) {} + + @Get() + findAll() { + return this.tagsService.findAll(); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR) + @Post() + create(@Body() dto: CreateTagDto) { + return this.tagsService.create(dto); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR) + @Patch(':id') + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTagDto) { + return this.tagsService.update(id, dto); + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.EDITOR) + @Delete(':id') + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.tagsService.remove(id); + } +} diff --git a/src/module/tags/tags.module.ts b/src/module/tags/tags.module.ts new file mode 100644 index 0000000..b8c26cf --- /dev/null +++ b/src/module/tags/tags.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { AuthModule } from '../auth/auth.module'; +import { UsersModule } from '../users/users.module'; +import { TagsController } from './tags.controller'; +import { TagsService } from './tags.service'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'keycloak' }), + AuthModule, + UsersModule, + ], + controllers: [TagsController], + providers: [TagsService], +}) +export class TagsModule {} diff --git a/src/module/tags/tags.service.ts b/src/module/tags/tags.service.ts new file mode 100644 index 0000000..df05447 --- /dev/null +++ b/src/module/tags/tags.service.ts @@ -0,0 +1,44 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { slugify } from '../../shared/utils/slug'; +import { CreateTagDto } from './dto/create-tag.dto'; +import { UpdateTagDto } from './dto/update-tag.dto'; + +@Injectable() +export class TagsService { + constructor(private readonly prisma: PrismaService) {} + + findAll() { + return this.prisma.tag.findMany({ orderBy: { name: 'asc' } }); + } + + async create(dto: CreateTagDto) { + const slug = dto.slug?.trim() || slugify(dto.name); + return this.prisma.tag.create({ + data: { name: dto.name, slug }, + }); + } + + async update(id: string, dto: UpdateTagDto) { + await this.ensureExists(id); + return this.prisma.tag.update({ + where: { id }, + data: { + name: dto.name, + slug: dto.slug?.trim(), + }, + }); + } + + async remove(id: string) { + await this.ensureExists(id); + await this.prisma.tag.delete({ where: { id } }); + return { deleted: true }; + } + + private async ensureExists(id: string) { + const row = await this.prisma.tag.findUnique({ where: { id } }); + if (!row) throw new NotFoundException('Tag not found'); + return row; + } +} diff --git a/src/module/users/dto/update-me.dto.ts b/src/module/users/dto/update-me.dto.ts new file mode 100644 index 0000000..0b4d87d --- /dev/null +++ b/src/module/users/dto/update-me.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateMeDto { + @IsOptional() + @IsString() + @MaxLength(120) + displayName?: string; + + @IsOptional() + @IsString() + @MaxLength(512) + avatarKey?: string; +} diff --git a/src/module/users/user-provisioning.guard.ts b/src/module/users/user-provisioning.guard.ts new file mode 100644 index 0000000..abe91ac --- /dev/null +++ b/src/module/users/user-provisioning.guard.ts @@ -0,0 +1,23 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { KeycloakRequestUser } from '../../shared/decorators/current-user.decorator'; +import { UsersService } from './users.service'; + +@Injectable() +export class UserProvisioningGuard implements CanActivate { + constructor(private readonly usersService: UsersService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const keycloakUser = req.user as KeycloakRequestUser | undefined; + if (!keycloakUser?.userId) { + throw new UnauthorizedException(); + } + req.dbUser = await this.usersService.ensureUser(keycloakUser); + return true; + } +} diff --git a/src/module/users/users.controller.ts b/src/module/users/users.controller.ts new file mode 100644 index 0000000..70f4370 --- /dev/null +++ b/src/module/users/users.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { CurrentDbUser } from '../../shared/decorators/current-db-user.decorator'; +import { User } from '@prisma/client'; +import { UpdateMeDto } from './dto/update-me.dto'; +import { UsersService } from './users.service'; +import { UserProvisioningGuard } from './user-provisioning.guard'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard) + @Get('me') + me(@CurrentDbUser() user: User) { + return user; + } + + @UseGuards(AuthGuard('keycloak'), UserProvisioningGuard) + @Patch('me') + updateMe(@CurrentDbUser() user: User, @Body() dto: UpdateMeDto) { + return this.usersService.updateProfile(user.id, dto); + } +} diff --git a/src/module/users/users.module.ts b/src/module/users/users.module.ts new file mode 100644 index 0000000..0eabdc9 --- /dev/null +++ b/src/module/users/users.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { AuthModule } from '../auth/auth.module'; +import { UserProvisioningGuard } from './user-provisioning.guard'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'keycloak' }), + AuthModule, + ], + controllers: [UsersController], + providers: [UsersService, UserProvisioningGuard], + exports: [UsersService, UserProvisioningGuard], +}) +export class UsersModule {} diff --git a/src/module/users/users.service.ts b/src/module/users/users.service.ts new file mode 100644 index 0000000..d12cf57 --- /dev/null +++ b/src/module/users/users.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, User, UserRole } from '@prisma/client'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { KeycloakRequestUser } from '../../shared/decorators/current-user.decorator'; + +function mapKeycloakRolesToUserRole(realmRoles: string[]): UserRole { + const lower = realmRoles.map((r) => r.toLowerCase()); + if (lower.includes('admin')) return UserRole.ADMIN; + if (lower.includes('editor')) return UserRole.EDITOR; + if (lower.includes('author')) return UserRole.AUTHOR; + return UserRole.READER; +} + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async ensureUser(kc: KeycloakRequestUser): Promise { + const email = kc.email ?? `${kc.userId}@placeholder.local`; + const role = mapKeycloakRolesToUserRole(kc.roles ?? []); + + const existing = await this.prisma.user.findUnique({ + where: { keycloakId: kc.userId }, + }); + if (existing) { + const data: Prisma.UserUpdateInput = {}; + if (kc.email && kc.email !== existing.email) data.email = kc.email; + if (kc.name && kc.name !== existing.displayName) { + data.displayName = kc.name; + } + if (Object.keys(data).length === 0) { + return existing; + } + return this.prisma.user.update({ where: { id: existing.id }, data }); + } + + return this.prisma.user.create({ + data: { + keycloakId: kc.userId, + email, + displayName: kc.name ?? null, + role, + }, + }); + } + + async findById(id: string) { + return this.prisma.user.findUnique({ where: { id } }); + } + + async updateProfile(userId: string, dto: { displayName?: string; avatarKey?: string }) { + return this.prisma.user.update({ + where: { id: userId }, + data: { + displayName: dto.displayName, + avatarKey: dto.avatarKey, + }, + }); + } + + async setRole(userId: string, role: UserRole) { + return this.prisma.user.update({ where: { id: userId }, data: { role } }); + } +} diff --git a/src/shared/decorators/current-db-user.decorator.ts b/src/shared/decorators/current-db-user.decorator.ts new file mode 100644 index 0000000..6940a70 --- /dev/null +++ b/src/shared/decorators/current-db-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { User } from '@prisma/client'; + +export const CurrentDbUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): User => { + const req = ctx.switchToHttp().getRequest(); + return req.dbUser as User; + }, +); diff --git a/src/shared/decorators/current-user.decorator.ts b/src/shared/decorators/current-user.decorator.ts new file mode 100644 index 0000000..f7bf57e --- /dev/null +++ b/src/shared/decorators/current-user.decorator.ts @@ -0,0 +1,18 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export type KeycloakRequestUser = { + userId: string; + email?: string; + name?: string; + email_verified?: boolean; + picture?: string; + roles: string[]; + raw?: unknown; +}; + +export const CurrentKeycloakUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): KeycloakRequestUser => { + const req = ctx.switchToHttp().getRequest(); + return req.user as KeycloakRequestUser; + }, +); diff --git a/src/shared/decorators/roles.decorator.ts b/src/shared/decorators/roles.decorator.ts new file mode 100644 index 0000000..99ea607 --- /dev/null +++ b/src/shared/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '@prisma/client'; + +export const ROLES_KEY = 'roles'; + +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/shared/dto/pagination-query.dto.ts b/src/shared/dto/pagination-query.dto.ts new file mode 100644 index 0000000..ee6f5e2 --- /dev/null +++ b/src/shared/dto/pagination-query.dto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class PaginationQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/src/shared/guards/roles.guard.ts b/src/shared/guards/roles.guard.ts new file mode 100644 index 0000000..54b8d96 --- /dev/null +++ b/src/shared/guards/roles.guard.ts @@ -0,0 +1,30 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserRole } from '@prisma/client'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!required?.length) { + return true; + } + const req = context.switchToHttp().getRequest(); + const role = req.dbUser?.role as UserRole | undefined; + if (!role || !required.includes(role)) { + throw new ForbiddenException('Insufficient role'); + } + return true; + } +} diff --git a/src/shared/prisma/prisma.module.ts b/src/shared/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/src/shared/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/shared/prisma/prisma.service.ts b/src/shared/prisma/prisma.service.ts new file mode 100644 index 0000000..d79a3a5 --- /dev/null +++ b/src/shared/prisma/prisma.service.ts @@ -0,0 +1,30 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { Pool } from 'pg'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + private readonly pool: Pool; + + constructor(private readonly config: ConfigService) { + const connectionString = config.getOrThrow('databaseUrl'); + const pool = new Pool({ connectionString }); + const adapter = new PrismaPg(pool); + super({ adapter }); + this.pool = pool; + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + await this.pool.end(); + } +} diff --git a/src/shared/utils/slug.ts b/src/shared/utils/slug.ts new file mode 100644 index 0000000..b5544b3 --- /dev/null +++ b/src/shared/utils/slug.ts @@ -0,0 +1,10 @@ +export function slugify(input: string): string { + return input + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 120) || 'item'; +} diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 0000000..4af96da --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,11 @@ +import { User } from '@prisma/client'; + +declare global { + namespace Express { + interface Request { + dbUser?: User; + } + } +} + +export {};