diff --git a/.env.example b/.env.example index 09bcd33..e419ed8 100644 --- a/.env.example +++ b/.env.example @@ -2,19 +2,8 @@ PORT=3000 NODE_ENV=development -# Keycloak OpenID Connect Configuration -# The base URL of your Keycloak server (e.g., https://keycloak.example.com) -KEYCLOAK_AUTH_SERVER_URL=https://your-keycloak-instance.com/auth - -# The Keycloak realm name -KEYCLOAK_REALM=your-realm-name - -# The client ID registered in Keycloak for this application -KEYCLOAK_CLIENT_ID=friendolls-api - -# The client secret (required if the client is confidential) -# Leave empty if using a public client -KEYCLOAK_CLIENT_SECRET= +# Database connection string +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/friendolls_dev?schema=public" # JWT Configuration # The expected issuer of the JWT token (usually {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM}) diff --git a/.gitignore b/.gitignore index 4b56acf..78cff21 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +/generated/prisma diff --git a/README.md b/README.md index 65dd5bd..fe96b5b 100644 --- a/README.md +++ b/README.md @@ -1 +1,218 @@ -Friendolls. Backend edition. +# Friendolls Server + +Backend API for the Friendolls application built with NestJS, Keycloak authentication, and Prisma ORM. + +## Tech Stack + +- **Framework:** [NestJS](https://nestjs.com/) - Progressive Node.js framework +- **Authentication:** [Keycloak](https://www.keycloak.org/) - OpenID Connect (OIDC) / OAuth 2.0 +- **Database ORM:** [Prisma](https://www.prisma.io/) - Next-generation TypeScript ORM +- **Database:** PostgreSQL +- **Language:** TypeScript +- **Package Manager:** pnpm + +## Features + +- ✅ Keycloak OIDC authentication with JWT tokens +- ✅ User management synchronized from Keycloak +- ✅ PostgreSQL database with Prisma ORM +- ✅ Swagger API documentation +- ✅ Role-based access control +- ✅ Global exception handling +- ✅ Environment-based configuration +- ✅ Comprehensive logging + +## Prerequisites + +- Node.js 18+ +- pnpm +- PostgreSQL 14+ (or Docker) +- Keycloak instance (for authentication) + +## Getting Started + +### 1. Install Dependencies + +```bash +pnpm install +``` + +### 2. Set Up Environment Variables + +Create a `.env` file in the root directory: + +```env +# Server Configuration +PORT=3000 +NODE_ENV=development + +# Database +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/friendolls_dev?schema=public" + +# Keycloak OIDC Configuration +JWKS_URI=https://your-keycloak-instance.com/realms/your-realm/protocol/openid-connect/certs +JWT_ISSUER=https://your-keycloak-instance.com/realms/your-realm +JWT_AUDIENCE=your-client-id +``` + +### 3. Set Up PostgreSQL Database + +**Option A: Using Docker (Recommended for development)** + +```bash +docker run --name friendolls-postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=friendolls_dev \ + -p 5432:5432 \ + -d postgres:16-alpine +``` + +**Option B: Use existing PostgreSQL installation** + +```bash +createdb friendolls_dev +``` + +See [docs/PRISMA_SETUP.md](docs/PRISMA_SETUP.md) for detailed database setup instructions. + +### 4. Generate Prisma Client and Run Migrations + +```bash +# Generate Prisma Client (creates TypeScript types) +pnpm prisma:generate + +# Run migrations to create database schema +pnpm prisma:migrate + +# If migration already exists, it will say "Already in sync" +``` + +**Important:** Always run `pnpm prisma:generate` after pulling new code or changing the Prisma schema. + +### 5. Start the Development Server + +```bash +pnpm start:dev +``` + +The server will be running at `http://localhost:3000`. + +## Available Scripts + +### Development + +- `pnpm start:dev` - Start development server with hot reload +- `pnpm start:debug` - Start development server with debugging + +### Production + +- `pnpm build` - Build the application +- `pnpm start:prod` - Start production server + +### Database (Prisma) + +- `pnpm prisma:generate` - Generate Prisma Client +- `pnpm prisma:migrate` - Create and apply database migration +- `pnpm prisma:migrate:deploy` - Apply migrations in production +- `pnpm prisma:studio` - Open Prisma Studio (visual database browser) +- `pnpm db:push` - Push schema changes (dev only) +- `pnpm db:reset` - Reset database and reapply migrations + +### Code Quality + +- `pnpm lint` - Lint the code +- `pnpm format` - Format code with Prettier + +### Testing + +- `pnpm test` - Run unit tests +- `pnpm test:watch` - Run tests in watch mode +- `pnpm test:cov` - Run tests with coverage +- `pnpm test:e2e` - Run end-to-end tests + +## API Documentation + +Once the server is running, access the Swagger API documentation at: + +``` +http://localhost:3000/api +``` + +## Project Structure + +``` +friendolls-server/ +├── prisma/ # Prisma configuration +│ ├── migrations/ # Database migrations +│ └── schema.prisma # Database schema +├── src/ +│ ├── auth/ # Authentication module (Keycloak OIDC) +│ ├── common/ # Shared utilities and decorators +│ ├── config/ # Configuration +│ ├── database/ # Database module (Prisma) +│ │ ├── prisma.service.ts +│ │ └── database.module.ts +│ ├── users/ # Users module +│ │ ├── dto/ # Data Transfer Objects +│ │ ├── users.controller.ts +│ │ ├── users.service.ts +│ │ └── users.entity.ts +│ ├── app.module.ts # Root application module +│ └── main.ts # Application entry point +├── test/ # E2E tests +├── .env # Environment variables (gitignored) +├── package.json +└── tsconfig.json +``` + +## Database Schema + +The application uses Prisma with PostgreSQL. The main entity is: + +### User Model + +- Synchronized from Keycloak OIDC +- Stores user profile information +- Tracks login history +- Manages user roles + +## Authentication Flow + +1. User authenticates via Keycloak +2. Keycloak issues JWT token +3. Client sends JWT in `Authorization: Bearer ` header +4. Server validates JWT against Keycloak's JWKS +5. User is automatically created/synced from token on first login +6. Subsequent requests update user's last login time + +## Development Workflow + +1. **Make schema changes** in `prisma/schema.prisma` +2. **Create migration**: `pnpm prisma:migrate` +3. **Implement business logic** in services +4. **Create/update DTOs** for request validation +5. **Update controllers** for API endpoints +6. **Test** your changes +7. **Lint and format**: `pnpm lint && pnpm format` +8. **Commit** your changes + +## Environment Variables + +| Variable | Description | Required | Example | +| -------------- | ---------------------------- | ------------------ | ------------------------------------------------------------------- | +| `PORT` | Server port | No (default: 3000) | `3000` | +| `NODE_ENV` | Environment | No | `development`, `production` | +| `DATABASE_URL` | PostgreSQL connection string | **Yes** | `postgresql://user:pass@host:5432/db` | +| `JWKS_URI` | Keycloak JWKS endpoint | **Yes** | `https://keycloak.com/realms/myrealm/protocol/openid-connect/certs` | +| `JWT_ISSUER` | JWT issuer (Keycloak realm) | **Yes** | `https://keycloak.com/realms/myrealm` | +| `JWT_AUDIENCE` | JWT audience (client ID) | **Yes** | `my-client-id` | + +## Production Deployment + +1. **Set environment variables** (especially `DATABASE_URL`) +2. **Install dependencies**: `pnpm install --prod` +3. **Generate Prisma Client**: `pnpm prisma:generate` +4. **Run migrations**: `pnpm prisma:migrate:deploy` +5. **Build application**: `pnpm build` +6. **Start server**: `pnpm start:prod` diff --git a/eslint.config.mjs b/eslint.config.mjs index a76ca21..5db221d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,6 +29,7 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unsafe-return': 'error', 'prettier/prettier': ['error', { endOfLine: 'auto' }], }, }, @@ -38,6 +39,7 @@ export default tseslint.config( '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/unbound-method': 'off', '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', }, }, ); diff --git a/package.json b/package.json index a9494bc..18216f3 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,14 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:migrate:deploy": "prisma migrate deploy", + "prisma:studio": "prisma studio", + "prisma:seed": "ts-node prisma/seed.ts", + "db:reset": "prisma migrate reset", + "db:push": "prisma db push" }, "dependencies": { "@nestjs/common": "^11.0.1", @@ -26,12 +33,15 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.3", + "@prisma/adapter-pg": "^7.0.0", + "@prisma/client": "^7.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "dotenv": "^17.2.3", "jwks-rsa": "^3.2.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -44,6 +54,8 @@ "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", + "@types/passport-jwt": "^4.0.1", + "@types/pg": "^8.15.6", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -51,6 +63,7 @@ "globals": "^16.0.0", "jest": "^30.0.0", "prettier": "^3.4.2", + "prisma": "^7.0.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 39e609a..21537ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: '@nestjs/swagger': specifier: ^11.2.3 version: 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + '@prisma/adapter-pg': + specifier: ^7.0.0 + version: 7.0.0 + '@prisma/client': + specifier: ^7.0.0 + version: 7.0.0(prisma@7.0.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3) class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -44,6 +50,9 @@ importers: passport-jwt: specifier: ^4.0.1 version: 4.0.1 + pg: + specifier: ^8.16.3 + version: 8.16.3 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -75,18 +84,24 @@ importers: '@types/node': specifier: ^22.10.7 version: 22.19.1 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/pg': + specifier: ^8.15.6 + version: 8.15.6 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 eslint: specifier: ^9.18.0 - version: 9.39.1 + version: 9.39.1(jiti@2.6.1) eslint-config-prettier: specifier: ^10.0.1 - version: 10.1.8(eslint@9.39.1) + version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.2.2 - version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2) + version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) globals: specifier: ^16.0.0 version: 16.5.0 @@ -96,6 +111,9 @@ importers: prettier: specifier: ^3.4.2 version: 3.6.2 + prisma: + specifier: ^7.0.0 + version: 7.0.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -119,7 +137,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.20.0 - version: 8.47.0(eslint@9.39.1)(typescript@5.9.3) + version: 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) packages: @@ -322,6 +340,18 @@ packages: '@borewit/text-codec@0.1.1': resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} + '@chevrotain/cst-dts-gen@10.5.0': + resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} + + '@chevrotain/gast@10.5.0': + resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==} + + '@chevrotain/types@10.5.0': + resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==} + + '@chevrotain/utils@10.5.0': + resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -330,6 +360,20 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@electric-sql/pglite-socket@0.0.6': + resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite-tools@0.2.7': + resolution: {integrity: sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg==} + peerDependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite@0.3.2': + resolution: {integrity: sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==} + '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -377,6 +421,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hono/node-server@1.14.2': + resolution: {integrity: sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==} + 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'} @@ -667,6 +717,10 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@mrleebo/prisma-ast@0.12.1': + resolution: {integrity: sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==} + engines: {node: '>=16'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -812,6 +866,64 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@prisma/adapter-pg@7.0.0': + resolution: {integrity: sha512-cis1Ib+TVbtSi2VU5Zm1fSmAcA8jg7KwUTbJ6LcWIm1eww380utXM8G23F3UCEPOJO4HiKt6adP/Q1hukjAkgw==} + + '@prisma/client-runtime-utils@7.0.0': + resolution: {integrity: sha512-PAiFgMBPrLSaakBwUpML5NevipuKSL3rtNr8pZ8CZ3OBXo0BFcdeGcBIKw/CxJP6H4GNa4+l5bzJPrk8Iq6tDw==} + + '@prisma/client@7.0.0': + resolution: {integrity: sha512-FM1NtJezl0zH3CybLxcbJwShJt7xFGSRg+1tGhy3sCB8goUDnxnBR+RC/P35EAW8gjkzx7kgz7bvb0MerY2VSw==} + engines: {node: ^20.19 || ^22.12 || ^24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.0.0': + resolution: {integrity: sha512-TDASB57hyGUwHB0IPCSkoJcXFrJOKA1+R/1o4np4PbS+E0F5MiY5aAyUttO0mSuNQaX7t8VH/GkDemffF1mQzg==} + + '@prisma/debug@6.8.2': + resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==} + + '@prisma/debug@7.0.0': + resolution: {integrity: sha512-SdS3qzfMASHtWimywtkiRcJtrHzacbmMVhElko3DYUZSB0TTLqRYWpddRBJdeGgSLmy1FD55p7uGzIJ+MtfhMg==} + + '@prisma/dev@0.13.0': + resolution: {integrity: sha512-QMmF6zFeUF78yv1HYbHvod83AQnl7u6NtKyDhTRZOJup3h1icWs8R7RUVxBJZvM2tBXNAMpLQYYM/8kPlOPegA==} + + '@prisma/driver-adapter-utils@7.0.0': + resolution: {integrity: sha512-ZEvzFaIapnfNKFPgZu/Zy4g6jfO5C0ZmMp+IjO9hNKNDwVKrDlBKw7F3Y9oRK0U0kfb9lKWP4Dz7DgtKs4TTbA==} + + '@prisma/engines-version@6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513': + resolution: {integrity: sha512-7bzyN8Gp9GbDFbTDzVUH9nFcgRWvsWmjrGgBJvIC/zEoAuv/lx62gZXgAKfjn/HoPkxz/dS+TtsnduFx8WA+cw==} + + '@prisma/engines@7.0.0': + resolution: {integrity: sha512-ojCL3OFLMCz33UbU9XwH32jwaeM+dWb8cysTuY8eK6ZlMKXJdy6ogrdG3MGB3meKLGdQBmOpUUGJ7eLIaxbrcg==} + + '@prisma/fetch-engine@7.0.0': + resolution: {integrity: sha512-qcyWTeWDjVDaDQSrVIymZU1xCYlvmwCzjA395lIuFjUESOH3YQCb8i/hpd4vopfq3fUR4v6+MjjtIGvnmErQgw==} + + '@prisma/get-platform@6.8.2': + resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==} + + '@prisma/get-platform@7.0.0': + resolution: {integrity: sha512-zyhzrAa+y/GfyCzTnuk0D9lfkvDzo7IbsNyuhTqhPu/AN0txm0x26HAR4tJLismla/fHf5fBzYwSivYSzkpakg==} + + '@prisma/query-plan-executor@6.18.0': + resolution: {integrity: sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==} + + '@prisma/studio-core-licensed@0.8.0': + resolution: {integrity: sha512-SXCcgFvo/SC6/11kEOaQghJgCWNEWZUvPYKn/gpvMB9HLSG/5M8If7dWZtEQHhchvl8bh9A89Hw6mEKpsXFimA==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -824,6 +936,9 @@ packages: '@sinonjs/fake-timers@13.0.5': resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@tokenizer/inflate@0.3.1': resolution: {integrity: sha512-4oeoZEBQdLdt5WmP/hx1KZ6D3/Oid/0cUb2nk4F0pTDAWy+KCH3/EnAkZF/bvckWo8I33EqBm01lIPgmgc8rCA==} engines: {node: '>=18'} @@ -921,12 +1036,27 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.2.6': + resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -1275,6 +1405,10 @@ packages: 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.2.0: resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1356,6 +1490,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'} @@ -1390,6 +1532,9 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chevrotain@10.5.0: + resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1402,6 +1547,9 @@ packages: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + cjs-module-lexer@2.1.1: resolution: {integrity: sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==} @@ -1474,6 +1622,9 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1523,6 +1674,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'} @@ -1543,6 +1697,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'} @@ -1550,14 +1708,24 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + 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'} @@ -1577,6 +1745,10 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -1594,6 +1766,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + electron-to-chromium@1.5.259: resolution: {integrity: sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==} @@ -1607,6 +1782,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'} @@ -1751,6 +1930,13 @@ packages: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} 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==} @@ -1858,6 +2044,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'} @@ -1874,6 +2063,9 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-port-please@3.1.2: + resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1882,6 +2074,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@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1921,6 +2117,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammex@3.1.11: + resolution: {integrity: sha512-HNwLkgRg9SqTAd1N3Uh/MnKwTBTzwBxTOPbXQ8pb0tpwydjk90k4zRE8JUn9fMUiRwKtXFZ1TWFmms3dZHN+Fg==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1945,6 +2144,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono@4.7.10: + resolution: {integrity: sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==} + engines: {node: '>=16.9.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1952,6 +2155,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'} @@ -2029,6 +2235,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'} @@ -2203,6 +2412,10 @@ packages: node-notifier: optional: true + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} @@ -2276,6 +2489,10 @@ packages: libphonenumber-js@1.12.29: resolution: {integrity: sha512-P2aLrbeqHbmh8+9P35LXQfXOKc7XJ0ymUKl7tyeyQjdRNfzunXWxQXGc4yl3fUf28fqLRfPY+vIVvFXK7KEBTw==} + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + limiter@1.1.5: resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} @@ -2335,6 +2552,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==} @@ -2349,9 +2569,17 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lru-memoizer@2.3.0: resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + lru.min@1.1.3: + resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} + 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==} @@ -2458,6 +2686,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.3: + resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} + engines: {node: '>=12.0.0'} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2479,6 +2715,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==} @@ -2493,6 +2732,11 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nypm@0.6.2: + resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2501,6 +2745,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'} @@ -2593,9 +2840,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.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + 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==} @@ -2619,10 +2906,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.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + 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'} @@ -2640,6 +2954,22 @@ packages: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prisma@7.0.0: + resolution: {integrity: sha512-VZObZ1pQV/OScarYg68RYUx61GpFLH2mJGf9fUX4XxQxTst/6ZK7nkY86CSZ3zBW6U9lKRTsBrZWVz20X5G/KQ==} + 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'} @@ -2648,6 +2978,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==} @@ -2669,9 +3002,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.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -2683,6 +3028,12 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + + remeda@2.21.3: + resolution: {integrity: sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2707,6 +3058,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'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2730,6 +3085,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + 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'} @@ -2751,6 +3109,9 @@ packages: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -2814,9 +3175,17 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + 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'} @@ -2825,6 +3194,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -2928,6 +3300,10 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -3097,6 +3473,14 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@1.1.0: + resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validator@13.15.23: resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==} engines: {node: '>= 0.10'} @@ -3198,6 +3582,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zeptomatch@2.0.2: + resolution: {integrity: sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==} + snapshots: '@angular-devkit/core@19.2.17(chokidar@4.0.3)': @@ -3445,6 +3832,21 @@ snapshots: '@borewit/text-codec@0.1.1': {} + '@chevrotain/cst-dts-gen@10.5.0': + dependencies: + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/gast@10.5.0': + dependencies: + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/types@10.5.0': {} + + '@chevrotain/utils@10.5.0': {} + '@colors/colors@1.5.0': optional: true @@ -3452,6 +3854,16 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)': + dependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite-tools@0.2.7(@electric-sql/pglite@0.3.2)': + dependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite@0.3.2': {} + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -3468,9 +3880,9 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1)': + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': dependencies: - eslint: 9.39.1 + eslint: 9.39.1(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -3514,6 +3926,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@hono/node-server@1.14.2(hono@4.7.10)': + dependencies: + hono: 4.7.10 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -3902,6 +4318,11 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@mrleebo/prisma-ast@0.12.1': + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 @@ -4058,6 +4479,93 @@ snapshots: '@pkgr/core@0.2.9': {} + '@prisma/adapter-pg@7.0.0': + dependencies: + '@prisma/driver-adapter-utils': 7.0.0 + pg: 8.16.3 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.0.0': {} + + '@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.0.0 + optionalDependencies: + prisma: 7.0.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.0.0': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.8.2': {} + + '@prisma/debug@7.0.0': {} + + '@prisma/dev@0.13.0(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.3.2 + '@electric-sql/pglite-socket': 0.0.6(@electric-sql/pglite@0.3.2) + '@electric-sql/pglite-tools': 0.2.7(@electric-sql/pglite@0.3.2) + '@hono/node-server': 1.14.2(hono@4.7.10) + '@mrleebo/prisma-ast': 0.12.1 + '@prisma/get-platform': 6.8.2 + '@prisma/query-plan-executor': 6.18.0 + foreground-child: 3.3.1 + get-port-please: 3.1.2 + hono: 4.7.10 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.21.3 + std-env: 3.9.0 + valibot: 1.1.0(typescript@5.9.3) + zeptomatch: 2.0.2 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.0.0': + dependencies: + '@prisma/debug': 7.0.0 + + '@prisma/engines-version@6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513': {} + + '@prisma/engines@7.0.0': + dependencies: + '@prisma/debug': 7.0.0 + '@prisma/engines-version': 6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513 + '@prisma/fetch-engine': 7.0.0 + '@prisma/get-platform': 7.0.0 + + '@prisma/fetch-engine@7.0.0': + dependencies: + '@prisma/debug': 7.0.0 + '@prisma/engines-version': 6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513 + '@prisma/get-platform': 7.0.0 + + '@prisma/get-platform@6.8.2': + dependencies: + '@prisma/debug': 6.8.2 + + '@prisma/get-platform@7.0.0': + dependencies: + '@prisma/debug': 7.0.0 + + '@prisma/query-plan-executor@6.18.0': {} + + '@prisma/studio-core-licensed@0.8.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@types/react': 19.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + '@scarf/scarf@1.4.0': {} '@sinclair/typebox@0.34.41': {} @@ -4070,6 +4578,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.0.0': {} + '@tokenizer/inflate@0.3.1': dependencies: debug: 4.4.3 @@ -4198,10 +4708,34 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.5 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.5 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 22.19.1 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} + '@types/react@19.2.6': + dependencies: + csstype: 3.2.3 + '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 @@ -4239,15 +4773,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/type-utils': 8.47.0(eslint@9.39.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.47.0 - eslint: 9.39.1 + eslint: 9.39.1(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -4256,14 +4790,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.47.0(eslint@9.39.1)(typescript@5.9.3)': + '@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.47.0 '@typescript-eslint/types': 8.47.0 '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.47.0 debug: 4.4.3 - eslint: 9.39.1 + eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4286,13 +4820,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.47.0(eslint@9.39.1)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.47.0 '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.1 + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -4316,13 +4850,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.47.0(eslint@9.39.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.47.0 '@typescript-eslint/types': 8.47.0 '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - eslint: 9.39.1 + eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4564,6 +5098,8 @@ snapshots: asynckit@0.4.0: {} + aws-ssl-profiles@1.1.2: {} + babel-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -4686,6 +5222,21 @@ snapshots: bytes@3.1.2: {} + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + 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 @@ -4713,6 +5264,15 @@ snapshots: chardet@2.1.1: {} + chevrotain@10.5.0: + dependencies: + '@chevrotain/cst-dts-gen': 10.5.0 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + '@chevrotain/utils': 10.5.0 + lodash: 4.17.21 + regexp-to-ast: 0.5.0 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -4721,6 +5281,10 @@ snapshots: ci-info@4.3.1: {} + citty@0.1.6: + dependencies: + consola: 3.4.2 + cjs-module-lexer@2.1.1: {} class-transformer@0.5.1: {} @@ -4788,6 +5352,8 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + confbox@0.2.2: {} + consola@3.4.2: {} content-disposition@1.0.1: {} @@ -4826,6 +5392,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -4834,16 +5402,24 @@ snapshots: 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.4: {} + 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: @@ -4859,6 +5435,8 @@ snapshots: dotenv@16.4.7: {} + dotenv@16.6.1: {} + dotenv@17.2.3: {} dunder-proto@1.0.1: @@ -4875,6 +5453,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.0.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.259: {} emittery@0.13.1: {} @@ -4883,6 +5466,8 @@ snapshots: emoji-regex@9.2.2: {} + empathic@2.0.0: {} + encodeurl@2.0.0: {} enhanced-resolve@5.18.3: @@ -4919,19 +5504,19 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.1): + eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)): dependencies: - eslint: 9.39.1 + eslint: 9.39.1(jiti@2.6.1) - eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2): + eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2): dependencies: - eslint: 9.39.1 + eslint: 9.39.1(jiti@2.6.1) prettier: 3.6.2 prettier-linter-helpers: 1.0.0 synckit: 0.11.11 optionalDependencies: '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.8(eslint@9.39.1) + eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1)) eslint-scope@5.1.1: dependencies: @@ -4947,9 +5532,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.1: + eslint@9.39.1(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -4983,6 +5568,8 @@ snapshots: minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -5067,6 +5654,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: {} @@ -5197,6 +5790,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: {} @@ -5216,6 +5813,8 @@ snapshots: get-package-type@0.1.0: {} + get-port-please@3.1.2: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -5223,6 +5822,15 @@ snapshots: get-stream@6.0.1: {} + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.2 + pathe: 2.0.3 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5268,6 +5876,8 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.11: {} + graphemer@1.4.0: {} handlebars@4.7.8: @@ -5291,6 +5901,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.7.10: {} + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -5301,6 +5913,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-status-codes@2.3.0: {} + human-signals@2.1.0: {} iconv-lite@0.6.3: @@ -5356,6 +5970,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-stream@2.0.1: {} is-unicode-supported@0.1.0: {} @@ -5723,6 +6339,8 @@ snapshots: - supports-color - ts-node + jiti@2.6.1: {} + jose@4.15.9: {} js-tokens@4.0.0: {} @@ -5806,6 +6424,8 @@ snapshots: libphonenumber-js@1.12.29: {} + lilconfig@2.1.0: {} + limiter@1.1.5: {} lines-and-columns@1.2.4: {} @@ -5849,6 +6469,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + lru-cache@10.4.3: {} lru-cache@11.2.2: {} @@ -5861,11 +6483,15 @@ snapshots: dependencies: yallist: 4.0.0 + lru-cache@7.18.3: {} + lru-memoizer@2.3.0: dependencies: lodash.clonedeep: 4.5.0 lru-cache: 6.0.0 + lru.min@1.1.3: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5953,6 +6579,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.0 + long: 5.3.2 + lru.min: 1.1.3 + named-placeholders: 1.1.3 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.3: + dependencies: + lru-cache: 7.18.3 + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -5967,6 +6609,8 @@ snapshots: dependencies: lodash: 4.17.21 + node-fetch-native@1.6.7: {} + node-int64@0.4.0: {} node-releases@2.0.27: {} @@ -5977,10 +6621,20 @@ snapshots: dependencies: path-key: 3.1.1 + nypm@0.6.2: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 2.3.0 + tinyexec: 1.0.2 + 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 @@ -6080,8 +6734,47 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + pause@0.0.1: {} + perfect-debounce@1.0.0: {} + + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -6096,8 +6789,28 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + 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.0: {} + + 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.0: @@ -6112,6 +6825,28 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@7.0.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.0.0 + '@prisma/dev': 0.13.0(typescript@5.9.3) + '@prisma/engines': 7.0.0 + '@prisma/studio-core-licensed': 0.8.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - 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 @@ -6119,6 +6854,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + pure-rand@7.0.1: {} qs@6.14.0: @@ -6140,8 +6877,20 @@ snapshots: iconv-lite: 0.7.0 unpipe: 1.0.0 + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + react-is@18.3.1: {} + react@19.2.0: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -6152,6 +6901,12 @@ snapshots: reflect-metadata@0.2.2: {} + regexp-to-ast@0.5.0: {} + + remeda@2.21.3: + dependencies: + type-fest: 4.41.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -6169,6 +6924,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + retry@0.12.0: {} + reusify@1.1.0: {} router@2.2.0: @@ -6197,6 +6954,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 @@ -6230,6 +6989,8 @@ snapshots: transitivePeerDependencies: - supports-color + seq-queue@0.0.5: {} + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -6301,14 +7062,20 @@ snapshots: source-map@0.7.6: {} + 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.9.0: {} + streamsearch@1.1.0: {} string-length@4.0.2: @@ -6415,6 +7182,8 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + tinyexec@1.0.2: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -6519,13 +7288,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.47.0(eslint@9.39.1)(typescript@5.9.3): + typescript-eslint@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3) - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.1)(typescript@5.9.3) - eslint: 9.39.1 + '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6593,6 +7362,10 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@1.1.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validator@13.15.23: {} vary@1.1.2: {} @@ -6704,3 +7477,7 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} + + zeptomatch@2.0.2: + dependencies: + grammex: 3.1.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2f14a3d..0f71e7a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,6 @@ onlyBuiltDependencies: - '@nestjs/core' + - '@prisma/engines' + - '@scarf/scarf' + - prisma + - unrs-resolver diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..9c5e959 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma and assumes you have installed the following: +// npm install --save-dev prisma dotenv +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/migrations/20251123020958_init_users/migration.sql b/prisma/migrations/20251123020958_init_users/migration.sql new file mode 100644 index 0000000..ddf6175 --- /dev/null +++ b/prisma/migrations/20251123020958_init_users/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "keycloak_sub" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "username" TEXT, + "picture" TEXT, + "roles" TEXT[], + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "last_login_at" TIMESTAMP(3), + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_keycloak_sub_key" ON "users"("keycloak_sub"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..8c6ed78 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,47 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + output = "../node_modules/.prisma/client" +} + +datasource db { + provider = "postgresql" +} + +/// User model representing authenticated users from Keycloak OIDC +model User { + /// Internal unique identifier (UUID) + id String @id @default(uuid()) + + /// Keycloak subject identifier (unique per user in Keycloak) + /// This is the 'sub' claim from the JWT token + keycloakSub String @unique @map("keycloak_sub") + + /// User's display name + name String + + /// User's email address + email String + + /// User's preferred username from Keycloak + username String? + + /// URL to user's profile picture + picture String? + + /// User's roles from Keycloak (stored as JSON array) + roles String[] + + /// Timestamp when the user was first created in the system + createdAt DateTime @default(now()) @map("created_at") + + /// Timestamp when the user profile was last updated + updatedAt DateTime @updatedAt @map("updated_at") + + /// Timestamp of last login + lastLoginAt DateTime? @map("last_login_at") + + @@map("users") +} diff --git a/src/app.module.ts b/src/app.module.ts index 18b88ec..7b64fd3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; +import { DatabaseModule } from './database/database.module'; /** * Validates required environment variables. @@ -11,7 +12,12 @@ import { AuthModule } from './auth/auth.module'; * Returns the validated config. */ function validateEnvironment(config: Record): Record { - const requiredVars = ['JWKS_URI', 'JWT_ISSUER', 'JWT_AUDIENCE']; + const requiredVars = [ + 'JWKS_URI', + 'JWT_ISSUER', + 'JWT_AUDIENCE', + 'DATABASE_URL', + ]; const missingVars = requiredVars.filter((varName) => !config[varName]); @@ -42,6 +48,7 @@ function validateEnvironment(config: Record): Record { envFilePath: '.env', // Load from .env file validate: validateEnvironment, // Validate required environment variables }), + DatabaseModule, // Global database module for Prisma UsersModule, AuthModule, ], diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 633bc3c..a63dd4e 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -15,9 +15,6 @@ import { UsersModule } from '../users/users.module'; * - Integration with UsersModule for user synchronization * * The module requires the following environment variables: - * - KEYCLOAK_AUTH_SERVER_URL: Base URL of Keycloak server - * - KEYCLOAK_REALM: Keycloak realm name - * - KEYCLOAK_CLIENT_ID: Client ID registered in Keycloak * - JWT_ISSUER: Expected JWT issuer * - JWT_AUDIENCE: Expected JWT audience * - JWKS_URI: URI for fetching Keycloak's public keys diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 389cdad..db6524f 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -98,7 +98,7 @@ describe('AuthService', () => { username: 'testuser', picture: 'https://example.com/avatar.jpg', roles: ['user', 'premium'], - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + lastLoginAt: expect.any(Date), }), ); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index aaa14bf..ce692c8 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -28,12 +28,12 @@ export class AuthService { authenticatedUser; // Try to find existing user by Keycloak subject - let user = this.usersService.findByKeycloakSub(keycloakSub); + let user = await this.usersService.findByKeycloakSub(keycloakSub); if (user) { // User exists - update last login and sync profile data this.logger.debug(`Syncing existing user: ${keycloakSub}`); - user = this.usersService.updateFromToken(keycloakSub, { + user = await this.usersService.updateFromToken(keycloakSub, { email, name, username, @@ -44,7 +44,7 @@ export class AuthService { } else { // New user - create from token data this.logger.log(`Creating new user from token: ${keycloakSub}`); - user = this.usersService.createFromToken({ + user = await this.usersService.createFromToken({ keycloakSub, email: email || '', name: name || username || 'Unknown User', @@ -54,7 +54,7 @@ export class AuthService { }); } - return Promise.resolve(user); + return user; } /** diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts index ff6db89..e572977 100644 --- a/src/auth/decorators/current-user.decorator.ts +++ b/src/auth/decorators/current-user.decorator.ts @@ -40,10 +40,10 @@ export interface AuthenticatedUser { */ export const CurrentUser = createParamDecorator( (data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const request = ctx.switchToHttp().getRequest(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const user = request.user as AuthenticatedUser; + const request = ctx + .switchToHttp() + .getRequest<{ user?: AuthenticatedUser }>(); + const user = request.user; // If a specific property is requested, return only that property return data ? user?.[data] : user; diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts index 2993bdf..b887735 100644 --- a/src/auth/guards/jwt-auth.guard.ts +++ b/src/auth/guards/jwt-auth.guard.ts @@ -1,6 +1,8 @@ import { ExecutionContext, Injectable, Logger } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Observable } from 'rxjs'; +import type { Request } from 'express'; +import { User } from 'src/users/users.entity'; /** * JWT Authentication Guard @@ -29,13 +31,18 @@ export class JwtAuthGuard extends AuthGuard('jwt') { context: ExecutionContext, ): boolean | Promise | Observable { // Log the authentication attempt - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const request = context.switchToHttp().getRequest(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const request = context.switchToHttp().getRequest(); const authHeader = request.headers.authorization; if (!authHeader) { - this.logger.warn('Authentication attempt without Authorization header'); + this.logger.warn( + '❌ Authentication attempt without Authorization header', + ); + } else { + const tokenPreview = String(authHeader).substring(0, 20); + this.logger.debug( + `🔐 Authentication attempt with token: ${tokenPreview}...`, + ); } return super.canActivate(context); @@ -50,24 +57,32 @@ export class JwtAuthGuard extends AuthGuard('jwt') { handleRequest( err: any, - user: any, + user: User, info: any, context: ExecutionContext, status?: any, ): any { + const hasMessage = (value: unknown): value is { message?: unknown } => + typeof value === 'object' && value !== null && 'message' in value; + if (err || !user) { - const infoMessage = - info && typeof info === 'object' && 'message' in info - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - String(info.message) - : ''; - const errMessage = - err && typeof err === 'object' && 'message' in err - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - String(err.message) - : ''; - this.logger.warn( - `Authentication failed: ${infoMessage || errMessage || 'Unknown error'}`, + const infoMessage = hasMessage(info) ? String(info.message) : ''; + const errMessage = hasMessage(err) ? String(err.message) : ''; + + this.logger.error(`❌ JWT Authentication failed`); + this.logger.error(` Error: ${errMessage || 'none'}`); + this.logger.error(` Info: ${infoMessage || 'none'}`); + + if (info && typeof info === 'object') { + this.logger.error(` Info details: ${JSON.stringify(info)}`); + } + + if (err && typeof err === 'object') { + this.logger.error(` Error details: ${JSON.stringify(err)}`); + } + } else { + this.logger.debug( + `✅ JWT Authentication successful for user: ${user.keycloakSub || 'unknown'}`, ); } diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 4bc8052..5b9caae 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, ExtractJwt } from 'passport-jwt'; + import { passportJwtSecret } from 'jwks-rsa'; /** @@ -74,7 +75,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) { algorithms: ['RS256'], }); - this.logger.log(`JWT Strategy initialized with issuer: ${issuer}`); + this.logger.log(`JWT Strategy initialized`); + this.logger.log(` JWKS URI: ${jwksUri}`); + this.logger.log(` Issuer: ${issuer}`); + this.logger.log(` Audience: ${audience || 'NOT SET'}`); } /** @@ -93,6 +97,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) { picture?: string; roles?: string[]; }> { + this.logger.debug(`Validating JWT token payload`); + this.logger.debug(` Issuer: ${payload.iss}`); + this.logger.debug( + ` Audience: ${Array.isArray(payload.aud) ? payload.aud.join(',') : payload.aud}`, + ); + this.logger.debug(` Subject: ${payload.sub}`); + this.logger.debug( + ` Expires: ${new Date(payload.exp * 1000).toISOString()}`, + ); + if (!payload.sub) { this.logger.warn('JWT token missing required "sub" claim'); throw new UnauthorizedException('Invalid token: missing subject'); @@ -120,7 +134,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { roles: roles.length > 0 ? roles : undefined, }; - this.logger.debug(`Validated token for user: ${payload.sub}`); + this.logger.log( + `✅ Successfully validated token for user: ${payload.sub} (${payload.email ?? payload.preferred_username ?? 'no email'})`, + ); return Promise.resolve(user); } diff --git a/src/database/database.module.ts b/src/database/database.module.ts new file mode 100644 index 0000000..8258f98 --- /dev/null +++ b/src/database/database.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +/** + * Database Module + * + * A global module that provides database access through PrismaService. + * This module is marked as @Global() so PrismaService is available + * throughout the application without needing to import DatabaseModule + * in every feature module. + */ +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class DatabaseModule {} diff --git a/src/database/prisma.service.ts b/src/database/prisma.service.ts new file mode 100644 index 0000000..4aee782 --- /dev/null +++ b/src/database/prisma.service.ts @@ -0,0 +1,134 @@ +import { + Injectable, + OnModuleInit, + OnModuleDestroy, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { Pool } from 'pg'; + +/** + * Type definitions for Prisma event payloads + */ +interface QueryEvent { + query: string; + params: string; + duration: number; +} + +interface ErrorEvent { + message: string; +} + +interface WarnEvent { + message: string; +} + +/** + * Prisma Service + * + * Manages the Prisma Client instance and database connection lifecycle. + * Automatically connects on module initialization and disconnects on module destruction. + * + * This service should be used throughout the application to interact with the database. + */ +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(PrismaService.name); + + constructor(private configService: ConfigService) { + const databaseUrl = configService.get('DATABASE_URL'); + + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + + // Initialize PostgreSQL connection pool + const pool = new Pool({ connectionString: databaseUrl }); + const adapter = new PrismaPg(pool); + + // Initialize Prisma Client with the adapter + super({ + adapter, + log: [ + { emit: 'event', level: 'query' }, + { emit: 'event', level: 'error' }, + { emit: 'event', level: 'warn' }, + ], + }); + + // Log database queries in development mode + if (process.env.NODE_ENV === 'development') { + this.$on('query' as never, (e: QueryEvent) => { + this.logger.debug(`Query: ${e.query}`); + this.logger.debug(`Params: ${e.params}`); + this.logger.debug(`Duration: ${e.duration}ms`); + }); + } + + // Log errors and warnings + + this.$on('error' as never, (e: ErrorEvent) => { + this.logger.error(`Database error: ${e.message}`); + }); + + this.$on('warn' as never, (e: WarnEvent) => { + this.logger.warn(`Database warning: ${e.message}`); + }); + } + + /** + * Connect to the database when the module is initialized + */ + async onModuleInit() { + try { + await this.$connect(); + this.logger.log('Successfully connected to database'); + } catch (error) { + this.logger.error('Failed to connect to database', error); + throw error; + } + } + + /** + * Disconnect from the database when the module is destroyed + */ + async onModuleDestroy() { + try { + await this.$disconnect(); + this.logger.log('Successfully disconnected from database'); + } catch (error) { + this.logger.error('Error disconnecting from database', error); + } + } + + /** + * Clean the database (useful for testing) + * WARNING: This will delete all data from all tables + */ + async cleanDatabase() { + if (process.env.NODE_ENV === 'production') { + throw new Error('Cannot clean database in production'); + } + + const models = Reflect.ownKeys(this).filter( + (key) => key[0] !== '_' && key[0] !== '$', + ); + + return Promise.all( + models.map((modelKey) => { + const model = this[modelKey as keyof this]; + if (model && typeof model === 'object' && 'deleteMany' in model) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + return (model as any).deleteMany(); + } + return Promise.resolve(); + }), + ); + } +} diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts index 0a4d31f..4aab09b 100644 --- a/src/users/users.controller.spec.ts +++ b/src/users/users.controller.spec.ts @@ -42,6 +42,7 @@ describe('UsersController', () => { email: 'test@example.com', name: 'Test User', username: 'testuser', + picture: null, roles: ['user'], createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), @@ -74,20 +75,20 @@ describe('UsersController', () => { }); describe('getCurrentUser', () => { - it('should return current user profile and sync from token', async () => { + it('should return the current user and sync from token', async () => { mockSyncUserFromToken.mockResolvedValue(mockUser); const result = await controller.getCurrentUser(mockAuthUser); - expect(result).toEqual(mockUser); + expect(result).toBe(mockUser); expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser); }); }); describe('updateCurrentUser', () => { - it('should update current user profile', async () => { + it('should update the current user profile', async () => { const updateDto: UpdateUserDto = { name: 'Updated Name' }; - const updatedUser: User = { ...mockUser, name: 'Updated Name' }; + const updatedUser = { ...mockUser, name: 'Updated Name' }; mockSyncUserFromToken.mockResolvedValue(mockUser); mockUpdate.mockResolvedValue(updatedUser); @@ -97,7 +98,7 @@ describe('UsersController', () => { updateDto, ); - expect(result).toEqual(updatedUser); + expect(result).toBe(updatedUser); expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser); expect(mockUpdate).toHaveBeenCalledWith( mockUser.id, @@ -139,35 +140,33 @@ describe('UsersController', () => { }); describe('update', () => { - it('should update a user by id', async () => { - const updateDto: UpdateUserDto = { name: 'Updated Name' }; - const updatedUser: User = { ...mockUser, name: 'Updated Name' }; + it('should update a user profile', async () => { + const updateDto: UpdateUserDto = { name: 'New Name' }; + const updatedUser = { ...mockUser, name: 'New Name' }; - mockUpdate.mockReturnValue(updatedUser); + mockUpdate.mockResolvedValue(updatedUser); const result = await controller.update( - 'uuid-123', + mockUser.id, updateDto, mockAuthUser, ); - expect(result).toEqual(updatedUser); + expect(result).toBe(updatedUser); expect(mockUpdate).toHaveBeenCalledWith( - 'uuid-123', + mockUser.id, updateDto, mockAuthUser.keycloakSub, ); }); - it('should throw ForbiddenException when trying to update another user', async () => { - const updateDto: UpdateUserDto = { name: 'Updated Name' }; - - mockUpdate.mockImplementation(() => { - throw new ForbiddenException('You can only update your own profile'); - }); + it('should throw ForbiddenException if updating another user', async () => { + mockUpdate.mockRejectedValue( + new ForbiddenException('You can only update your own profile'), + ); await expect( - controller.update('different-uuid', updateDto, mockAuthUser), + controller.update(mockUser.id, { name: 'Hacker' }, mockAuthUser), ).rejects.toThrow(ForbiddenException); }); @@ -185,9 +184,9 @@ describe('UsersController', () => { }); describe('deleteCurrentUser', () => { - it('should delete current user account', async () => { + it('should delete the current user account', async () => { mockSyncUserFromToken.mockResolvedValue(mockUser); - mockDelete.mockReturnValue(undefined); + mockDelete.mockResolvedValue(undefined); await controller.deleteCurrentUser(mockAuthUser); @@ -200,35 +199,33 @@ describe('UsersController', () => { }); describe('delete', () => { - it('should delete a user by id', () => { - mockDelete.mockReturnValue(undefined); + it('should delete a user by ID', async () => { + mockDelete.mockResolvedValue(undefined); - controller.delete('uuid-123', mockAuthUser); + await controller.delete(mockUser.id, mockAuthUser); expect(mockDelete).toHaveBeenCalledWith( - 'uuid-123', + mockUser.id, mockAuthUser.keycloakSub, ); }); - it('should throw ForbiddenException when trying to delete another user', () => { - mockDelete.mockImplementation(() => { - throw new ForbiddenException('You can only delete your own account'); - }); - - expect(() => controller.delete('different-uuid', mockAuthUser)).toThrow( - ForbiddenException, + it('should throw ForbiddenException if deleting another user', async () => { + mockDelete.mockRejectedValue( + new ForbiddenException('You can only delete your own account'), ); + + await expect( + controller.delete(mockUser.id, mockAuthUser), + ).rejects.toThrow(ForbiddenException); }); - it('should throw NotFoundException if user not found', () => { - mockDelete.mockImplementation(() => { - throw new NotFoundException('User with ID non-existent not found'); - }); + it('should throw NotFoundException if user not found', async () => { + mockDelete.mockRejectedValue(new NotFoundException('User not found')); - expect(() => controller.delete('non-existent', mockAuthUser)).toThrow( - NotFoundException, - ); + await expect( + controller.delete('non-existent-id', mockAuthUser), + ).rejects.toThrow(NotFoundException); }); }); }); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index d3e542a..7a0ac28 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -19,7 +19,7 @@ import { ApiForbiddenResponse, } from '@nestjs/swagger'; import { UsersService } from './users.service'; -import { User } from './users.entity'; +import { User, UserResponseDto } from './users.entity'; import { UpdateUserDto } from './dto/update-user.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { @@ -62,7 +62,7 @@ export class UsersController { @ApiResponse({ status: 200, description: 'Current user profile', - type: User, + type: UserResponseDto, }) @ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token', @@ -90,7 +90,7 @@ export class UsersController { @ApiResponse({ status: 200, description: 'User profile updated successfully', - type: User, + type: UserResponseDto, }) @ApiResponse({ status: 400, @@ -109,8 +109,10 @@ export class UsersController { const user = await this.authService.syncUserFromToken(authUser); // Update the user's profile - return Promise.resolve( - this.usersService.update(user.id, updateUserDto, authUser.keycloakSub), + return this.usersService.update( + user.id, + updateUserDto, + authUser.keycloakSub, ); } @@ -132,7 +134,7 @@ export class UsersController { @ApiResponse({ status: 200, description: 'User found', - type: User, + type: UserResponseDto, }) @ApiResponse({ status: 404, @@ -148,7 +150,7 @@ export class UsersController { this.logger.debug( `Get user by ID: ${id} (requested by ${authUser.keycloakSub})`, ); - return Promise.resolve(this.usersService.findOne(id)); + return this.usersService.findOne(id); } /** @@ -169,7 +171,7 @@ export class UsersController { @ApiResponse({ status: 200, description: 'User updated successfully', - type: User, + type: UserResponseDto, }) @ApiResponse({ status: 400, @@ -191,9 +193,7 @@ export class UsersController { @CurrentUser() authUser: AuthenticatedUser, ): Promise { this.logger.log(`Update user ${id} (requested by ${authUser.keycloakSub})`); - return Promise.resolve( - this.usersService.update(id, updateUserDto, authUser.keycloakSub), - ); + return this.usersService.update(id, updateUserDto, authUser.keycloakSub); } /** @@ -224,7 +224,7 @@ export class UsersController { const user = await this.authService.syncUserFromToken(authUser); // Delete the user's account - this.usersService.delete(user.id, authUser.keycloakSub); + await this.usersService.delete(user.id, authUser.keycloakSub); } /** @@ -257,11 +257,11 @@ export class UsersController { description: 'Invalid or missing JWT token', }) @HttpCode(204) - delete( + async delete( @Param('id') id: string, @CurrentUser() authUser: AuthenticatedUser, - ): void { + ): Promise { this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`); - this.usersService.delete(id, authUser.keycloakSub); + await this.usersService.delete(id, authUser.keycloakSub); } } diff --git a/src/users/users.entity.ts b/src/users/users.entity.ts index b023544..d225508 100644 --- a/src/users/users.entity.ts +++ b/src/users/users.entity.ts @@ -1,102 +1,85 @@ import { ApiProperty } from '@nestjs/swagger'; +import { User as PrismaUser } from '@prisma/client'; /** * User entity representing a user in the system. * Users are synced from Keycloak via OIDC authentication. + * + * This is a re-export of the Prisma User type for consistency. + * Swagger decorators are applied at the controller level. */ -export class User { - /** - * Internal unique identifier (UUID) - */ +export type User = PrismaUser; + +/** + * User response DTO for Swagger documentation + * This class is only used for API documentation purposes + */ +export class UserResponseDto implements PrismaUser { @ApiProperty({ description: 'Internal unique identifier', example: '550e8400-e29b-41d4-a716-446655440000', }) id: string; - /** - * Keycloak subject identifier (unique per user in Keycloak) - */ @ApiProperty({ description: 'Keycloak subject identifier from the JWT token', example: 'f:a1b2c3d4-e5f6-7890-abcd-ef1234567890:johndoe', }) keycloakSub: string; - /** - * User's display name - */ @ApiProperty({ description: "User's display name", example: 'John Doe', }) name: string; - /** - * User's email address - */ @ApiProperty({ description: "User's email address", example: 'john.doe@example.com', }) email: string; - /** - * User's preferred username from Keycloak - */ @ApiProperty({ description: "User's preferred username from Keycloak", example: 'johndoe', required: false, + nullable: true, }) - username?: string; + username: string | null; - /** - * URL to user's profile picture - */ @ApiProperty({ description: "URL to user's profile picture", example: 'https://example.com/avatars/johndoe.jpg', required: false, + nullable: true, }) - picture?: string; + picture: string | null; - /** - * User's roles from Keycloak - */ @ApiProperty({ description: "User's roles from Keycloak", example: ['user', 'premium'], type: [String], - required: false, + isArray: true, }) - roles?: string[]; + roles: string[]; - /** - * Timestamp when the user was first created in the system - */ @ApiProperty({ description: 'Timestamp when the user was first created', example: '2024-01-15T10:30:00.000Z', }) createdAt: Date; - /** - * Timestamp when the user profile was last updated - */ @ApiProperty({ description: 'Timestamp when the user was last updated', example: '2024-01-20T14:45:00.000Z', }) updatedAt: Date; - /** - * Timestamp of last login - */ @ApiProperty({ description: 'Timestamp of last login', example: '2024-01-20T14:45:00.000Z', required: false, + nullable: true, }) - lastLoginAt?: Date; + lastLoginAt: Date | null; } diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 5abca93..85b4689 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -1,17 +1,52 @@ import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { UsersService } from './users.service'; +import { PrismaService } from '../database/prisma.service'; import type { UpdateUserDto } from './dto/update-user.dto'; +import type { User } from '@prisma/client'; describe('UsersService', () => { let service: UsersService; + let prismaService: PrismaService; + + const mockUser: User = { + id: '550e8400-e29b-41d4-a716-446655440000', + keycloakSub: 'f:realm:user123', + email: 'john@example.com', + name: 'John Doe', + username: 'johndoe', + picture: 'https://example.com/avatar.jpg', + roles: ['user', 'premium'], + createdAt: new Date('2024-01-15T10:30:00.000Z'), + updatedAt: new Date('2024-01-15T10:30:00.000Z'), + lastLoginAt: new Date('2024-01-15T10:30:00.000Z'), + }; + + const mockPrismaService = { + user: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService], + providers: [ + UsersService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], }).compile(); service = module.get(UsersService); + prismaService = module.get(PrismaService); + + // Clear all mocks before each test + jest.clearAllMocks(); }); it('should be defined', () => { @@ -19,7 +54,7 @@ describe('UsersService', () => { }); describe('createFromToken', () => { - it('should create a new user from Keycloak token data', () => { + it('should create a new user from Keycloak token data', async () => { const tokenData = { keycloakSub: 'f:realm:user123', email: 'john@example.com', @@ -29,259 +64,258 @@ describe('UsersService', () => { roles: ['user', 'premium'], }; - const user = service.createFromToken(tokenData); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockResolvedValue(mockUser); + + const user = await service.createFromToken(tokenData); expect(user).toBeDefined(); expect(user.id).toBeDefined(); - expect(typeof user.id).toBe('string'); expect(user.keycloakSub).toBe('f:realm:user123'); expect(user.email).toBe('john@example.com'); expect(user.name).toBe('John Doe'); expect(user.username).toBe('johndoe'); expect(user.picture).toBe('https://example.com/avatar.jpg'); expect(user.roles).toEqual(['user', 'premium']); - expect(user.createdAt).toBeInstanceOf(Date); - expect(user.updatedAt).toBeInstanceOf(Date); - expect(user.lastLoginAt).toBeInstanceOf(Date); + + expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ + where: { keycloakSub: tokenData.keycloakSub }, + }); + expect(mockPrismaService.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + keycloakSub: tokenData.keycloakSub, + email: tokenData.email, + name: tokenData.name, + username: tokenData.username, + picture: tokenData.picture, + roles: tokenData.roles, + }), + }); }); - it('should return existing user if keycloakSub already exists', () => { + it('should return existing user if keycloakSub already exists', async () => { const tokenData = { keycloakSub: 'f:realm:user123', email: 'john@example.com', name: 'John Doe', }; - const user1 = service.createFromToken(tokenData); - const user2 = service.createFromToken(tokenData); + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); - expect(user1.id).toBe(user2.id); - expect(user1.keycloakSub).toBe(user2.keycloakSub); + const user = await service.createFromToken(tokenData); + + expect(user).toEqual(mockUser); + expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ + where: { keycloakSub: tokenData.keycloakSub }, + }); + expect(mockPrismaService.user.create).not.toHaveBeenCalled(); + }); + + it('should handle optional fields', async () => { + const tokenData = { + keycloakSub: 'f:realm:user456', + email: 'jane@example.com', + name: 'Jane Doe', + }; + + const newUser = { ...mockUser, username: null, picture: null, roles: [] }; + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockResolvedValue(newUser); + + const user = await service.createFromToken(tokenData); + + expect(user).toBeDefined(); + expect(mockPrismaService.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + keycloakSub: tokenData.keycloakSub, + email: tokenData.email, + name: tokenData.name, + username: undefined, + picture: undefined, + roles: [], + }), + }); }); }); describe('findByKeycloakSub', () => { - it('should return the user if found by keycloakSub', () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'test@example.com', - name: 'Test User', + it('should return a user by keycloakSub', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + + const user = await service.findByKeycloakSub('f:realm:user123'); + + expect(user).toEqual(mockUser); + expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ + where: { keycloakSub: 'f:realm:user123' }, }); - - const user = service.findByKeycloakSub('f:realm:user123'); - - expect(user).toBeDefined(); - expect(user?.id).toBe(createdUser.id); - expect(user?.keycloakSub).toBe('f:realm:user123'); }); - it('should return null if user not found by keycloakSub', () => { - const user = service.findByKeycloakSub('non-existent-sub'); + it('should return null if user not found', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + const user = await service.findByKeycloakSub('nonexistent'); + expect(user).toBeNull(); + expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ + where: { keycloakSub: 'nonexistent' }, + }); }); }); describe('findOne', () => { - it('should return the user if found by ID', () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'test@example.com', - name: 'Test User', + it('should return a user by ID', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + + const user = await service.findOne(mockUser.id); + + expect(user).toEqual(mockUser); + expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ + where: { id: mockUser.id }, }); - - const user = service.findOne(createdUser.id); - - expect(user).toEqual(createdUser); }); - it('should throw NotFoundException if user not found by ID', () => { - expect(() => service.findOne('non-existent-id')).toThrow( + it('should throw NotFoundException if user not found', async () => { + const nonexistentId = 'nonexistent-id'; + mockPrismaService.user.findUnique.mockResolvedValue(null); + + await expect(service.findOne(nonexistentId)).rejects.toThrow( NotFoundException, ); - expect(() => service.findOne('non-existent-id')).toThrow( - 'User with ID non-existent-id not found', + await expect(service.findOne(nonexistentId)).rejects.toThrow( + `User with ID ${nonexistentId} not found`, ); }); }); describe('updateFromToken', () => { - it('should update user data from token and set lastLoginAt', async () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'old@example.com', - name: 'Old Name', - }); - - const originalUpdatedAt = createdUser.updatedAt; - - // Wait a bit to ensure timestamp difference - await new Promise((resolve) => setTimeout(resolve, 10)); - - const updatedUser = service.updateFromToken('f:realm:user123', { - email: 'new@example.com', - name: 'New Name', - username: 'newusername', - roles: ['admin'], - lastLoginAt: new Date(), - }); - - expect(updatedUser.id).toBe(createdUser.id); - expect(updatedUser.email).toBe('new@example.com'); - expect(updatedUser.name).toBe('New Name'); - expect(updatedUser.username).toBe('newusername'); - expect(updatedUser.roles).toEqual(['admin']); - expect(updatedUser.lastLoginAt).toBeDefined(); - expect(updatedUser.updatedAt.getTime()).toBeGreaterThan( - originalUpdatedAt.getTime(), - ); - }); - - it('should throw NotFoundException if user not found', () => { - expect(() => - service.updateFromToken('non-existent-sub', { - email: 'test@example.com', - }), - ).toThrow(NotFoundException); - }); - - it('should only update provided fields', () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'original@example.com', - name: 'Original Name', - username: 'original', - }); - - const updatedUser = service.updateFromToken('f:realm:user123', { + it('should update user from token data', async () => { + const updateData = { email: 'updated@example.com', - // name and username not provided - }); + name: 'Updated Name', + lastLoginAt: new Date('2024-01-20T14:45:00.000Z'), + }; - expect(updatedUser.email).toBe('updated@example.com'); - expect(updatedUser.name).toBe('Original Name'); // unchanged - expect(updatedUser.username).toBe('original'); // unchanged + const updatedUser = { ...mockUser, ...updateData }; + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.update.mockResolvedValue(updatedUser); + + const user = await service.updateFromToken('f:realm:user123', updateData); + + expect(user).toEqual(updatedUser); + expect(mockPrismaService.user.update).toHaveBeenCalledWith({ + where: { keycloakSub: 'f:realm:user123' }, + data: expect.objectContaining({ + email: updateData.email, + name: updateData.name, + lastLoginAt: updateData.lastLoginAt, + }), + }); + }); + + it('should throw NotFoundException if user not found', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + await expect( + service.updateFromToken('nonexistent', { name: 'Test' }), + ).rejects.toThrow(NotFoundException); + }); + + it('should only update provided fields', async () => { + const updateData = { + name: 'New Name', + }; + + const updatedUser = { ...mockUser, ...updateData }; + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.update.mockResolvedValue(updatedUser); + + await service.updateFromToken('f:realm:user123', updateData); + + expect(mockPrismaService.user.update).toHaveBeenCalledWith({ + where: { keycloakSub: 'f:realm:user123' }, + data: { name: 'New Name' }, + }); }); }); describe('update', () => { - it('should update user profile when user updates their own profile', () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'test@example.com', - name: 'Old Name', - }); - - const updateUserDto: UpdateUserDto = { - name: 'New Name', + it('should update user profile', async () => { + const updateDto: UpdateUserDto = { + name: 'Updated Name', }; - const updatedUser = service.update( - createdUser.id, - updateUserDto, - 'f:realm:user123', + const updatedUser = { ...mockUser, name: updateDto.name }; + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.update.mockResolvedValue(updatedUser); + + const user = await service.update( + mockUser.id, + updateDto, + mockUser.keycloakSub, ); - expect(updatedUser.id).toBe(createdUser.id); - expect(updatedUser.name).toBe('New Name'); - }); - - it('should throw ForbiddenException when user tries to update another user', () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'test@example.com', - name: 'Test User', + expect(user.name).toBe(updateDto.name); + expect(mockPrismaService.user.update).toHaveBeenCalledWith({ + where: { id: mockUser.id }, + data: { name: updateDto.name }, }); - - const updateUserDto: UpdateUserDto = { name: 'New Name' }; - - expect(() => - service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'), - ).toThrow(ForbiddenException); - - expect(() => - service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'), - ).toThrow('You can only update your own profile'); }); - it('should throw NotFoundException if user not found', () => { - const updateUserDto: UpdateUserDto = { name: 'New Name' }; + it('should throw ForbiddenException if user tries to update someone else', async () => { + const updateDto: UpdateUserDto = { + name: 'Hacker Name', + }; - expect(() => - service.update('non-existent-id', updateUserDto, 'f:realm:user123'), - ).toThrow(NotFoundException); + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + + await expect( + service.update(mockUser.id, updateDto, 'different-keycloak-sub'), + ).rejects.toThrow(ForbiddenException); + + expect(mockPrismaService.user.update).not.toHaveBeenCalled(); }); - it('should update user profile with empty name', () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'test@example.com', - name: 'Old Name', - }); + it('should throw NotFoundException if user not found', async () => { + const updateDto: UpdateUserDto = { + name: 'Test', + }; - const updateUserDto: UpdateUserDto = { name: '' }; + mockPrismaService.user.findUnique.mockResolvedValue(null); - const updatedUser = service.update( - createdUser.id, - updateUserDto, - 'f:realm:user123', - ); - - expect(updatedUser.name).toBe(''); - }); - - it('should update user profile with very long name', () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'test@example.com', - name: 'Old Name', - }); - - const longName = 'A'.repeat(200); // Exceeds typical limits - const updateUserDto: UpdateUserDto = { name: longName }; - - const updatedUser = service.update( - createdUser.id, - updateUserDto, - 'f:realm:user123', - ); - - expect(updatedUser.name).toBe(longName); + await expect( + service.update('nonexistent', updateDto, 'any-keycloak-sub'), + ).rejects.toThrow(NotFoundException); }); }); describe('delete', () => { - it('should delete user when user deletes their own account', () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'delete@example.com', - name: 'To Delete', + it('should delete user account', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.delete.mockResolvedValue(mockUser); + + await service.delete(mockUser.id, mockUser.keycloakSub); + + expect(mockPrismaService.user.delete).toHaveBeenCalledWith({ + where: { id: mockUser.id }, }); - - service.delete(createdUser.id, 'f:realm:user123'); - - expect(() => service.findOne(createdUser.id)).toThrow(NotFoundException); }); - it('should throw ForbiddenException when user tries to delete another user', () => { - const createdUser = service.createFromToken({ - keycloakSub: 'f:realm:user123', - email: 'test@example.com', - name: 'Test User', - }); + it('should throw ForbiddenException if user tries to delete someone else', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); - expect(() => - service.delete(createdUser.id, 'f:realm:differentuser'), - ).toThrow(ForbiddenException); + await expect( + service.delete(mockUser.id, 'different-keycloak-sub'), + ).rejects.toThrow(ForbiddenException); - expect(() => - service.delete(createdUser.id, 'f:realm:differentuser'), - ).toThrow('You can only delete your own account'); + expect(mockPrismaService.user.delete).not.toHaveBeenCalled(); }); - it('should throw NotFoundException if user not found', () => { - expect(() => - service.delete('non-existent-id', 'f:realm:user123'), - ).toThrow(NotFoundException); + it('should throw NotFoundException if user not found', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + await expect( + service.delete('nonexistent', 'any-keycloak-sub'), + ).rejects.toThrow(NotFoundException); }); }); }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 67119a7..d8384ce 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -4,8 +4,8 @@ import { ForbiddenException, Logger, } from '@nestjs/common'; -import { randomUUID } from 'crypto'; -import { User } from './users.entity'; +import { PrismaService } from '../database/prisma.service'; +import { User } from '@prisma/client'; import type { UpdateUserDto } from './dto/update-user.dto'; /** @@ -35,14 +35,15 @@ export interface UpdateUserFromTokenDto { /** * Users Service * - * Manages user data synchronized from Keycloak OIDC. + * Manages user data synchronized from Keycloak OIDC using Prisma ORM. * Users are created automatically when they first authenticate via Keycloak. * Direct user creation is not allowed - users must authenticate via Keycloak first. */ @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); - private users: User[] = []; + + constructor(private readonly prisma: PrismaService) {} /** * Creates a new user from Keycloak token data. @@ -51,10 +52,9 @@ export class UsersService { * @param createDto - User data extracted from Keycloak JWT token * @returns The newly created user */ - createFromToken(createDto: CreateUserFromTokenDto): User { - const existingUser = this.users.find( - (u) => u.keycloakSub === createDto.keycloakSub, - ); + async createFromToken(createDto: CreateUserFromTokenDto): Promise { + // Check if user already exists + const existingUser = await this.findByKeycloakSub(createDto.keycloakSub); if (existingUser) { this.logger.warn( @@ -63,19 +63,17 @@ export class UsersService { return existingUser; } - const newUser = new User(); - newUser.id = randomUUID(); - newUser.keycloakSub = createDto.keycloakSub; - newUser.email = createDto.email; - newUser.name = createDto.name; - newUser.username = createDto.username; - newUser.picture = createDto.picture; - newUser.roles = createDto.roles; - newUser.createdAt = new Date(); - newUser.updatedAt = new Date(); - newUser.lastLoginAt = new Date(); - - this.users.push(newUser); + const newUser = await this.prisma.user.create({ + data: { + keycloakSub: createDto.keycloakSub, + email: createDto.email, + name: createDto.name, + username: createDto.username, + picture: createDto.picture, + roles: createDto.roles || [], + lastLoginAt: new Date(), + }, + }); this.logger.log(`Created new user: ${newUser.id} (${newUser.keycloakSub})`); @@ -88,9 +86,12 @@ export class UsersService { * @param keycloakSub - The Keycloak subject (sub claim from JWT) * @returns The user if found, null otherwise */ - findByKeycloakSub(keycloakSub: string): User | null { - const user = this.users.find((u) => u.keycloakSub === keycloakSub); - return user || null; + async findByKeycloakSub(keycloakSub: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { keycloakSub }, + }); + + return user; } /** @@ -100,11 +101,15 @@ export class UsersService { * @returns The user entity * @throws NotFoundException if the user is not found */ - findOne(id: string): User { - const user = this.users.find((u) => u.id === id); + async findOne(id: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id }, + }); + if (!user) { throw new NotFoundException(`User with ID ${id} not found`); } + return user; } @@ -117,31 +122,47 @@ export class UsersService { * @returns The updated user * @throws NotFoundException if the user is not found */ - updateFromToken( + async updateFromToken( keycloakSub: string, updateDto: UpdateUserFromTokenDto, - ): User { - const user = this.findByKeycloakSub(keycloakSub); + ): Promise { + const user = await this.findByKeycloakSub(keycloakSub); + if (!user) { throw new NotFoundException( `User with keycloakSub ${keycloakSub} not found`, ); } - // Update user properties from token - if (updateDto.email !== undefined) user.email = updateDto.email; - if (updateDto.name !== undefined) user.name = updateDto.name; - if (updateDto.username !== undefined) user.username = updateDto.username; - if (updateDto.picture !== undefined) user.picture = updateDto.picture; - if (updateDto.roles !== undefined) user.roles = updateDto.roles; + // Prepare update data - only include defined fields + const updateData: { + email?: string; + name?: string; + username?: string; + picture?: string; + roles?: string[]; + lastLoginAt?: Date; + } = {}; + + if (updateDto.email !== undefined) updateData.email = updateDto.email; + if (updateDto.name !== undefined) updateData.name = updateDto.name; + if (updateDto.username !== undefined) + updateData.username = updateDto.username; + if (updateDto.picture !== undefined) updateData.picture = updateDto.picture; + if (updateDto.roles !== undefined) updateData.roles = updateDto.roles; if (updateDto.lastLoginAt !== undefined) - user.lastLoginAt = updateDto.lastLoginAt; + updateData.lastLoginAt = updateDto.lastLoginAt; - user.updatedAt = new Date(); + const updatedUser = await this.prisma.user.update({ + where: { keycloakSub }, + data: updateData, + }); - this.logger.debug(`Synced user from token: ${user.id} (${keycloakSub})`); + this.logger.debug( + `Synced user from token: ${updatedUser.id} (${keycloakSub})`, + ); - return user; + return updatedUser; } /** @@ -155,12 +176,12 @@ export class UsersService { * @throws NotFoundException if the user is not found * @throws ForbiddenException if the user tries to update someone else's profile */ - update( + async update( id: string, updateUserDto: UpdateUserDto, requestingUserKeycloakSub: string, - ): User { - const user = this.findOne(id); + ): Promise { + const user = await this.findOne(id); // Verify the user is updating their own profile if (user.keycloakSub !== requestingUserKeycloakSub) { @@ -172,15 +193,22 @@ export class UsersService { // Only allow updating specific fields via the public API // Security-sensitive fields (keycloakSub, roles, etc.) cannot be updated + const updateData: { + name?: string; + } = {}; + if (updateUserDto.name !== undefined) { - user.name = updateUserDto.name; + updateData.name = updateUserDto.name; } - user.updatedAt = new Date(); + const updatedUser = await this.prisma.user.update({ + where: { id }, + data: updateData, + }); this.logger.log(`User ${id} updated their profile`); - return user; + return updatedUser; } /** @@ -193,8 +221,8 @@ export class UsersService { * @throws NotFoundException if the user is not found * @throws ForbiddenException if the user tries to delete someone else's account */ - delete(id: string, requestingUserKeycloakSub: string): void { - const user = this.findOne(id); + async delete(id: string, requestingUserKeycloakSub: string): Promise { + const user = await this.findOne(id); // Verify the user is deleting their own account if (user.keycloakSub !== requestingUserKeycloakSub) { @@ -204,12 +232,9 @@ export class UsersService { throw new ForbiddenException('You can only delete your own account'); } - const index = this.users.findIndex((u) => u.id === id); - if (index === -1) { - throw new NotFoundException(`User with ID ${id} not found`); - } - - this.users.splice(index, 1); + await this.prisma.user.delete({ + where: { id }, + }); this.logger.log( `User ${id} deleted their account (Keycloak: ${requestingUserKeycloakSub})`,