diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82547ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Dependencies and build output +node_modules +dist +.pnpm-store + +# Git and IDE +.git +.gitignore +.cursor +.vscode +*.code-workspace + +# Env and secrets (use runtime env or secrets) +.env +.env.* +!.env.example + +# Tests and coverage +coverage +**/__tests__ +**/*.test.ts +**/*.spec.ts +jest.config.js +jest.setup.js + +# Docs and misc +docs +*.md +!README.md + +# CI +.github + +# Local and OS +.DS_Store +Thumbs.db +*.log diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f806b26 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +# EditorConfig: keep indentation consistent so format-on-save doesn't break spec.ts +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf diff --git a/.env.example b/.env.example index 1bf0e52..4f3124f 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,23 @@ -DATABASE_URL="" -SESSION_SECRET="" -REPL_ID="" -API_PROD_URL="" \ No newline at end of file +# Required: at least one of DATABASE_URL (dev) or DATABASE_URL_PROD (prod) +DATABASE_URL="postgresql://user:password@localhost:5432/museum" +# DATABASE_URL_PROD="postgresql://..." + +# Required: min 32 characters +SESSION_SECRET="your-super-secret-session-key-at-least-32-chars" + +# Server +PORT=5001 +NODE_ENV=development + +# CORS: allowed origin for browser requests (default http://localhost:3000) +FRONTEND_URL="http://localhost:3000" +# Optional: comma-separated extra origins +# CORS_ORIGINS="https://app.example.com,https://admin.example.com" + +# Production: set when NODE_ENV=production +# API_PROD_URL="https://api.example.com" + +# Optional: Cloudinary (for gallery uploads) +# CLOUDINARY_CLOUD_NAME= +# CLOUDINARY_API_KEY= +# CLOUDINARY_API_SECRET= diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..98684d7 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,24 @@ +/** + * ESLint config for TypeScript and Node.js (ESLint 8 compatible). + * Ensures consistent style and catches common issues before push/CI. + */ +module.exports = { + root: true, + env: { node: true, es2022: true }, + parser: "@typescript-eslint/parser", + parserOptions: { ecmaVersion: 2022, sourceType: "module" }, + plugins: ["@typescript-eslint"], + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier", + ], + ignorePatterns: ["node_modules", "dist", "*.js", "drizzle", "config/openapi"], + rules: { + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-require-imports": "off", + "no-console": ["warn", { allow: ["warn", "error"] }], + }, +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b740f3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,135 @@ +name: CI + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +jobs: + test: + name: Test and Lint + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "pnpm" + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check formatting + run: pnpm run format:check + + - name: Run linter + run: pnpm run lint + + - name: Type check + run: pnpm run check + + - name: Run tests + run: pnpm run test + env: + NODE_ENV: test + + - name: Build project + run: pnpm run build + + build: + name: Build + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: "pnpm" + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare for build + run: pnpm run prebuild + + - name: Build for production + run: pnpm run build:prod + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 7 + + docker: + name: Docker build + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: museum-management-rest-api:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 6868656..a52bb86 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ logs config/database/reset-db.ts todo-tasks.md config/database/seed.ts -config/database/badagry_backend.code-workspace.json +config/database/museum-management.code-workspace* .DS_Store *.swp *.swo diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f902733 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,23 @@ +# Dependencies and build +node_modules +dist +build +*.min.js + +# Lockfiles and generated +pnpm-lock.yaml +package-lock.json +*.config.js + +# Drizzle and migrations (generated) +drizzle/**/*.sql + +# Env (secrets) +.env +.env.* +!.env.example + +# OpenAPI spec (manual formatting; do not strip curly braces or reformat) +config/openapi/ +config/openapi/spec.ts +**/config/openapi/** diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1a88ab1 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c592854 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "editor.formatOnSave": true, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.detectIndentation": false, + "editor.tabSize": 2, + "editor.insertSpaces": true, + "prettier.ignorePath": ".prettierignore" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6fb2dac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# Museum Management REST API — multi-stage build (smallest image) +# ============================================================= +# Stage 1: Dependencies and build +FROM node:20-alpine AS builder + +RUN corepack enable && corepack prepare pnpm@latest --activate +WORKDIR /app + +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile + +COPY tsconfig.json ./ +COPY index.ts app.ts ./ +COPY config config/ +COPY middlewares middlewares/ +COPY server server/ +COPY drizzle.config.ts ./ +COPY drizzle drizzle/ + +RUN pnpm run build + +# Prune dev dependencies so runner only gets production node_modules +RUN pnpm prune --prod + +# Stage 2: Production runner (minimal: no pnpm, no dev deps) +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=5001 + +# Copy only runtime artifacts from builder (no source, no devDependencies) +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/drizzle ./drizzle +COPY --from=builder /app/drizzle.config.ts ./ + +# Non-root user for security +RUN addgroup -g 1001 -S app && adduser -u 1001 -S app -G app +USER app + +EXPOSE ${PORT} + +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index b31d69e..4531f75 100644 --- a/README.md +++ b/README.md @@ -1,321 +1,142 @@ -# Museum management REST-API - -A comprehensive RESTful API server for a digital museum platform, providing content management, user authentication, booking systems, and community forum features with real-time capabilities. - -## Overview - -This API serves as the backend infrastructure for a museum's digital presence, enabling visitors to explore historical content, book tours, engage in community discussions, and access gallery collections through a modern web platform. - -## Technology Stack - -- **Runtime**: Node.js 18+ -- **Language**: TypeScript 5.0+ -- **Framework**: Express.js 4.x -- **Database**: PostgreSQL 15+ (supports local and Neon serverless) -- **ORM**: Drizzle ORM -- **Authentication**: Passport.js with local strategy -- **File Storage**: Cloudinary via Multer -- **Validation**: Zod -- **Real-time**: WebSocket (ws library) -- **Session Management**: express-session with PostgreSQL store - -## Features - -### Core Functionality - -- **Authentication & Authorization**: Secure user registration and login with role-based access control (visitor, attendant, admin) -- **Historical Content Management**: Rich educational content with SEO-optimized routing and slug-based URLs -- **Gallery Management**: Categorized image collections with metadata and Cloudinary integration -- **Booking System**: Tour reservations with real-time status updates via WebSocket -- **Community Forum**: Interactive discussions with post likes, comments, and attendant-only sections -- **Contact Management**: Visitor inquiry handling and communication system -- **Admin Features**: Administrative functions for content and user management - -### Technical Features - -- Full TypeScript implementation with strict type checking -- Zod schema validation for request/response data -- Drizzle ORM with PostgreSQL support -- Cross-platform database support (local PostgreSQL and Neon serverless) -- Secure session handling with PostgreSQL session store -- Advanced querying with pagination, filtering, and relationship queries -- Connection pooling and efficient database operations -- Rate limiting for login attempts -- Comprehensive error handling middleware -- Request logging and monitoring +# Museum Management REST API + +

+ Node.js + TypeScript + Express + PostgreSQL + Drizzle ORM + OpenAPI + Zod + WebSocket +

+ +Production-ready REST API for a digital museum platform: content, auth, bookings, forum, and real-time features. Built with a **modular, microservice-friendly** design and standard Web API practices. + +## Key features + +| Capability | Description | +|------------|-------------| +| **RESTful API design** | Versioned base path (`/api/v1`), standard HTTP verbs (GET, POST, PUT, PATCH, DELETE), appropriate status codes (2xx, 4xx, 5xx), and JSON request/response bodies aligned with Web API best practices. | +| **Interactive API documentation** | OpenAPI 3.0 specification with Swagger UI at `GET /api-docs` and machine-readable spec at `GET /api-docs.json` for client generation and integration. | +| **Structured error handling** | Consistent JSON error payloads with `success`, `error.message`, `requestId`, `timestamp`, and `path`; 404 responses for unknown routes and predictable error semantics across all endpoints. | +| **Security & observability** | Helmet security headers, CORS policies, global and login-specific rate limiting, environment validation at startup, and health/live/ready probes for orchestration and monitoring. | +| **Testable architecture** | App factory pattern decouples app creation from server binding; unit and integration tests with automated CI pipeline (lint, typecheck, test, build). | + +## Tech stack + +| Layer | Choice | +| ---------- | ------------------------------------ | +| Runtime | Node.js 18+ | +| Language | TypeScript (strict) | +| Framework | Express 4.x | +| Database | PostgreSQL (local + Neon serverless) | +| ORM | Drizzle ORM | +| Auth | Passport.js (local), session-based | +| Validation | Zod | +| Security | Helmet, CORS, rate limiting | +| Real-time | WebSocket (ws) | ## Architecture -### Database Schema - -- **Users**: Authentication and profile management with role-based access -- **History Content**: Educational articles and historical information -- **Gallery Items**: Museum artifact and exhibition images with categorization -- **Bookings**: Tour reservations and visitor management -- **Forum Posts & Comments**: Community engagement platform with likes -- **Contact Messages**: Visitor communication system -- **Groups**: Forum group management with attendant-only access control - -### API Structure - -``` -/api/v1 -├── /auth # Authentication endpoints -├── /history # Historical content management -├── /gallery # Gallery and media management -├── /bookings # Tour booking system -├── /forum # Community forum features -├── /contact # Contact form and messaging -├── /admin # Administrative functions -├── /users # User profile management -└── /ws # WebSocket real-time communication -``` - -## Getting Started - -### Prerequisites - -- Node.js 18+ and pnpm (or npm/yarn) -- PostgreSQL 15+ (local) or Neon account (serverless) -- Git - -### Installation - -1. **Clone the repository** - ```bash - git clone - cd badagry_backend - ``` - -2. **Install dependencies** - ```bash - pnpm install - ``` - -3. **Environment setup** - ```bash - cp .env.example .env - ``` - - Configure your `.env` file: - ```env - # Database Configuration - DATABASE_URL=postgresql://username:password@localhost:5432/museum - # or for Neon: postgresql://user:pass@host/db?sslmode=require - - # Server Configuration - PORT=5001 - NODE_ENV=development - - # Session Secret - SESSION_SECRET=your-super-secret-session-key - - # CORS Configuration - FRONTEND_URL=http://localhost:3000 - API_PROD_URL=http://localhost:5001 - ``` - -4. **Database setup** - ```bash - # Generate and run migrations - pnpm run db:generate - pnpm run db:migrate - - # Sync the database - pnpm run db:push - - # Optional: Seed with sample data - pnpm run db:seed - ``` - -5. **Start the development server** - ```bash - pnpm run dev - ``` - -The API will be available at `http://localhost:5001/api/v1` - -## API Documentation - -### Authentication Endpoints - -```http -POST /api/v1/auth/register # User registration -POST /api/v1/auth/login # User login -POST /api/v1/auth/logout # User logout -GET /api/v1/auth/profile # Get user profile -PUT /api/v1/auth/profile # Update user profile -``` - -### Historical Content +- **Modular domains**: Each feature (history, gallery, bookings, forum, contact, admin, auth, users) lives in its own routes/controllers/services so domains can be split into microservices later. +- **App factory**: `createApp()` builds the Express app without starting the server for testability and clean process boundaries. +- **Env-first**: Config validated at startup via Zod; invalid env fails fast. +- **Structured errors**: Custom `AppError` types (e.g. `NotFoundError`, `ValidationError`) with correct HTTP status and consistent JSON error payloads including `requestId`. -```http -GET /api/v1/history # List all historical content -GET /api/v1/history/:id # Get content by ID -GET /api/v1/history/slug/:slug # Get content by slug -POST /api/v1/history # Create new content (admin only) -PUT /api/v1/history/:id # Update content (admin only) -DELETE /api/v1/history/:id # Delete content (admin only) -``` +## API base -### Gallery Management +All endpoints are under **`/api/v1`** (e.g. `GET /api/v1/health`). -```http -GET /api/v1/gallery # List all gallery items -GET /api/v1/gallery/:id # Get gallery item by ID -GET /api/v1/gallery/category/:category # Get items by category -POST /api/v1/gallery # Add new gallery item (admin only) -PUT /api/v1/gallery/:id # Update gallery item (admin only) -``` +| Area | Examples | +| --------- | -------------------------------------------------------------------------------------- | +| Health | `GET /api/v1/health` | +| Auth | `POST /api/v1/auth/register`, `POST /api/v1/auth/login`, `GET /api/v1/auth/profile` | +| History | `GET /api/v1/histories`, `GET /api/v1/histories/:id`, `POST /api/v1/histories` (admin) | +| Gallery | `GET /api/v1/gallery`, `GET /api/v1/gallery/category/:category` | +| Bookings | `GET /api/v1/bookings`, `POST /api/v1/bookings` | +| Forum | `GET /api/v1/forum/posts`, `POST /api/v1/forum/posts`, `POST /api/v1/forum/likes` | +| Contact | `POST /api/v1/contact` | +| Admin | Admin-only routes under `/api/v1/admin` | +| WebSocket | `/ws` on same server | -### Booking System +Responses use a consistent shape: `{ success, data?, error?, message?, pagination?, requestId? }`. Errors include `requestId` and `timestamp`. -```http -GET /api/v1/bookings # List user bookings -GET /api/v1/bookings/:id # Get booking details -POST /api/v1/bookings # Create new booking -PUT /api/v1/bookings/:id # Update booking status (attendant/admin) -``` +## Quick start -### Forum Features +**Requirements:** Node.js 18+, pnpm 8+ -```http -GET /api/v1/forum/posts # List forum posts -GET /api/v1/forum/posts/:id # Get post with comments -POST /api/v1/forum/posts # Create new post -POST /api/v1/forum/comments # Add comment to post -POST /api/v1/forum/likes # Like/unlike a post +```bash +git clone +cd repo_name +pnpm install +cp .env.example .env # then set DATABASE_URL, SESSION_SECRET, FRONTEND_URL, etc. +pnpm run db:push # or db:migrate +pnpm run dev # http://localhost:5001 ``` -### Response Format +- **API base:** [http://localhost:5001/api/v1](http://localhost:5001/api/v1) +- **Interactive API docs (Swagger UI):** [http://localhost:5001/api-docs](http://localhost:5001/api-docs) +- **OpenAPI JSON:** [http://localhost:5001/api-docs.json](http://localhost:5001/api-docs.json) -All API responses follow a consistent format: +**Important env vars** -```json -{ - "success": true, - "data": {...}, - "message": "Operation successful", - "pagination": { - "total": 100, - "hasMore": true, - "offset": 0, - "limit": 20 - } -} -``` - -## Configuration +| Variable | Description | +| ------------------- | ----------------------------------------------------- | +| `DATABASE_URL` | PostgreSQL URL (dev) | +| `DATABASE_URL_PROD` | Production DB (e.g. Neon) | +| `SESSION_SECRET` | Min 32 chars | +| `FRONTEND_URL` | Allowed CORS origin (default `http://localhost:3000`) | +| `CORS_ORIGINS` | Optional comma-separated extra origins | +| `PORT` | Server port (default `5001`) | -### Database Configuration +## Scripts -The system supports both local PostgreSQL and Neon serverless databases: +| Command | Purpose | +| --------------------------------------------- | ---------------------------------------------- | +| `pnpm run dev` | Dev server with hot reload | +| `pnpm run build` / `pnpm start` | Production build and run | +| `pnpm run check` | TypeScript check | +| `pnpm run test` | Unit + integration tests (includes CORS tests) | +| `pnpm run lint` | ESLint | +| `pnpm run db:push` / `db:migrate` / `db:seed` | Database | -- **Local Development**: Uses `pg` with connection pooling -- **Production**: Supports Neon serverless with WebSocket configuration +## Security & performance -### Environment Variables +- **CORS**: Env-based allowed origins (no wildcard when using credentials); preflight and headers tested. +- **Helmet**: Security headers (CSP, HSTS, etc.). +- **Rate limiting**: Applied on login and sensitive routes. +- **Request tracing**: `X-Request-ID` on every response; structured logging with request IDs. +- **Health**: `/api/v1/health` returns DB status and version; 503 when DB is down. -| Variable | Description | Default | -|----------|-------------|---------| -| `DATABASE_URL` | PostgreSQL connection string | Required | -| `PORT` | Server port | 5001 | -| `NODE_ENV` | Environment mode | development | -| `SESSION_SECRET` | Session encryption key | Required | -| `FRONTEND_URL` | CORS allowed origin | http://localhost:3000 | -| `API_PROD_URL` | Production server URI | http://localhost:5001 | +## Testing -## Development +- Unit tests for services (e.g. history service). +- CORS tests: allowed origin gets `Access-Control-Allow-Origin`, preflight returns 204, disallowed origin rejected. +- Run: `pnpm test`. -### Available Scripts +## Deployment -```bash -pnpm run dev # Start development server with hot reload -pnpm run build # Build TypeScript to JavaScript -pnpm run start # Start production server -pnpm run check # Type check without emitting files -pnpm run lint # Run ESLint -pnpm run test # Run tests -pnpm run db:generate # Generate database migrations -pnpm run db:migrate # Run database migrations -pnpm run db:push # Push schema changes to database -pnpm run db:seed # Seed database with sample data -pnpm run db:reset # Reset database -``` +- Build: `pnpm run build` then `node dist/index.js` (or `pnpm start`). +- Set `NODE_ENV=production`, `DATABASE_URL_PROD`, `API_PROD_URL`, and a strong `SESSION_SECRET`. +- Suitable for Node hosts (e.g. Render, Railway, Fly.io). -### Project Structure +## Project structure ``` -. -├── config/ # Configuration files -│ ├── auth/ # Authentication configuration -│ ├── bucket-storage/ # File upload configuration -│ ├── cors/ # CORS configuration -│ └── database/ # Database connection and schema -├── middlewares/ # Express middlewares -│ └── errors/ # Error handling +├── app.ts # Express app factory (no server listen) +├── index.ts # Entry: env, createApp, registerRoutes, listen +├── config/ # Env, auth, CORS, DB, OpenAPI, security +├── middlewares/ # Request ID, rate limit, errors, not-found ├── server/ -│ ├── controllers/ # Request handlers -│ ├── routes/ # Route definitions -│ ├── services/ # Business logic -│ └── utils/ # Utility functions -├── dist/ # Compiled JavaScript -├── drizzle/ # Database migrations -└── logs/ # Application logs +│ ├── routes/ # Route modules mounted under /api/v1 +│ ├── controllers/ # Request/response handling +│ ├── services/ # Business logic +│ └── types/ # API response types +├── docs/ # Feature summary, architecture notes +└── drizzle/ # Migrations ``` -## Testing - -```bash -# Run all tests -pnpm test - -# Run tests in watch mode -pnpm run test:watch - -# Run linting -pnpm run lint -``` - -## Deployment - -### Local Deployment - -```bash -pnpm run build -pnpm start -``` - -### Cloud Deployment - -The API is optimized for deployment on: -- Render (serverless functions) -- Other Node.js hosting platforms - -### Development Guidelines - -- Follow TypeScript best practices -- Maintain test coverage above 80% -- Use conventional commit messages -- Update documentation for new features -- Ensure backward compatibility -- Follow RESTful API design principles - -## Security Features - -- Password hashing with bcrypt -- Session-based authentication -- Role-based access control -- Rate limiting on login endpoints -- CORS configuration -- Input validation with Zod -- SQL injection prevention via Drizzle ORM -- Secure session cookies - ## License -This project is licensed under the MIT License. - -## Support - -For questions, issues, or contributions, please refer to the project repository or contact the development team. +MIT. diff --git a/app.ts b/app.ts new file mode 100644 index 0000000..e6ea186 --- /dev/null +++ b/app.ts @@ -0,0 +1,76 @@ +/** + * Application factory — creates Express app with middleware only (no routes). + * No server.listen here: allows testing and microservice extraction. + * Env must be validated before calling (e.g. by importing config/env first). + * CORS is applied here so all routes benefit from origin validation and preflight. + */ +import express, { RequestHandler } from "express"; +import type { ExpressApp, ExpressRequestLike, ExpressResponse } from "./types/express-app"; +import cors from "cors"; +import { env } from "./config/env/env-validation"; +import { getCorsOptions } from "./config/cors/cors-options"; +import { configureHelmet } from "./config/security/helmet-config"; +import { requestIdMiddleware } from "./middlewares/request-id"; +import { globalRateLimiter } from "./middlewares/global-rate-limit"; +import { logger } from "./config/logger/logger-config"; + +export function createApp(): ExpressApp { + const app = express() as unknown as ExpressApp; + + if (env.NODE_ENV === "production") { + app.set("trust proxy", 1); + } + + app.use(requestIdMiddleware); + app.use(express.json({ limit: "100mb" })); + app.use(express.urlencoded({ extended: false, limit: "100mb" })); + app.use(globalRateLimiter); + + app.use(cors(getCorsOptions())); + configureHelmet(app); + + // Request logging (API routes only) + const requestLoggingMiddleware: RequestHandler = ( + req: express.Request, + res: express.Response, + next: (err?: unknown) => void + ) => { + const logReq = req as unknown as ExpressRequestLike; + const logRes = res as unknown as ExpressResponse; + const start = Date.now(); + const path = logReq.path; + let capturedBody: Record | undefined; + + const originalJson = logRes.json.bind(res); + logRes.json = function (body: unknown) { + if (typeof body === "object" && body !== null) { + capturedBody = body as Record; + } + return originalJson(body); + }; + + logRes.on("finish", () => { + if (path.startsWith("/api/")) { + const duration = Date.now() - start; + const meta = { + method: logReq.method, + path, + status: logRes.statusCode, + durationMs: duration, + requestId: logReq.requestId, + }; + if (logRes.statusCode >= 500) { + logger.error("request", meta); + } else if (logRes.statusCode >= 400) { + logger.warn("request", meta); + } else { + logger.debug("request", capturedBody ? { ...meta, body: capturedBody } : meta); + } + } + }); + next(); + }; + app.use(requestLoggingMiddleware); + + return app; +} diff --git a/config/auth/auth-config.ts b/config/auth/auth-config.ts index 8c7bb99..d32a090 100644 --- a/config/auth/auth-config.ts +++ b/config/auth/auth-config.ts @@ -1,6 +1,8 @@ import passport from "passport"; import { Strategy as LocalStrategy } from "passport-local"; -import { Express, NextFunction, Response } from "express"; +import { Response } from "express"; +import type { ExpressApp, ExpressNext } from "../../types/express-app"; +import { UnauthorizedError, ForbiddenError } from "../../middlewares/errors/error-handler"; import session from "express-session"; import { scrypt, randomBytes, timingSafeEqual } from "crypto"; import { promisify } from "util"; @@ -9,119 +11,118 @@ import { User as SelectUser } from "../database/schema/schema-types"; import bcrypt from "bcrypt"; declare global { - namespace Express { - interface User extends SelectUser { } - } + namespace Express { + interface User extends SelectUser { } + } } const scryptAsync = promisify(scrypt); export async function hashPassword(password: string) { - const salt = await bcrypt.genSalt(10); // async version - const hashed = await bcrypt.hash(password, salt); + const salt = await bcrypt.genSalt(10); // async version + const hashed = await bcrypt.hash(password, salt); - return hashed; + return hashed; } - export async function comparePasswords(supplied: string, stored: string) { - const match = await bcrypt.compare(supplied, stored); + const match = await bcrypt.compare(supplied, stored); - return match; + return match; } - -export function configureAuth(app: Express) { - const sessionSecret = process.env.SESSION_SECRET || "museum_secret"; - - const sessionSettings: session.SessionOptions = { - secret: sessionSecret, - resave: false, - saveUninitialized: false, - store: sessionService.getSessionStore(), - cookie: { - maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days - httpOnly: true, - secure: process.env.NODE_ENV === "production", - } - }; - - // Configure session and passport - app.set("trust proxy", 1); - app.use(session(sessionSettings)); - app.use(passport.initialize()); - app.use(passport.session()); - - // Configure passport strategy - passport.use( - new LocalStrategy({ - usernameField: 'username', // or 'emailOrUsername' if that's what you're sending - passwordField: 'password' - }, async (username, password, done) => { - - - try { - // Check if input is email or username - let user; - if (username.includes('@')) { - user = await userService.getUserByEmail(username); - } else { - user = await userService.getUserByUsername(username); - } - - if (!user || !(await comparePasswords(password, user.password))) { - return done(null, false); - } else { - return done(null, user); - } - } catch (error) { - return done(error); - } - }), - ); - - // Serialize/deserialize user for session - passport.serializeUser((user, done) => done(null, user.id)); - - passport.deserializeUser(async (id: string, done) => { +import { env } from "../env/env-validation"; + +export function configureAuth(app: ExpressApp) { + const sessionSettings: session.SessionOptions = { + secret: env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + store: sessionService.getSessionStore(), + cookie: { + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days + httpOnly: true, + secure: env.NODE_ENV === "production", + }, + }; + + // Configure session and passport + app.set("trust proxy", 1); + app.use(session(sessionSettings)); + app.use(passport.initialize()); + app.use(passport.session()); + + // Configure passport strategy + passport.use( + new LocalStrategy( + { + usernameField: "username", // or 'emailOrUsername' if that's what you're sending + passwordField: "password", + }, + async (username, password, done) => { try { - const user = await userService.getUser(id); - done(null, user); + // Check if input is email or username + let user; + if (username.includes("@")) { + user = await userService.getUserByEmail(username); + } else { + user = await userService.getUserByUsername(username); + } + + if (!user || !(await comparePasswords(password, user.password))) { + return done(null, false); + } else { + return done(null, user); + } } catch (error) { - done(error); + return done(error); } - }); + } + ) + ); + + // Serialize/deserialize user for session + passport.serializeUser((user, done) => done(null, user.id)); + + passport.deserializeUser(async (id: string, done) => { + try { + const user = await userService.getUser(id); + done(null, user); + } catch (error) { + done(error); + } + }); } // Authentication middleware -export function requireAuth(req: Express.Request, res: Response, next: NextFunction) { - if (!req.isAuthenticated()) { - return res.status(401).json({ message: "Authentication required" }); - } - next(); +export function requireAuth(req: Express.Request, _res: Response, next: ExpressNext) { + if (!req.isAuthenticated()) { + return next(new UnauthorizedError("Authentication required")); + } + next(); } // Role-based middleware -export function requireAttendant(req: Express.Request, res: Response, next: NextFunction) { - if (!req.isAuthenticated()) { - return res.status(401).json({ message: "Authentication required" }); - } +export function requireAttendant(req: Express.Request, _res: Response, next: ExpressNext) { + if (!req.isAuthenticated()) { + return next(new UnauthorizedError("Authentication required")); + } - if (req.user.userType !== 'attendant' && req.user.userType !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } + if (req.user.userType !== "attendant" && req.user.userType !== "admin") { + return next(new ForbiddenError("Access denied")); + } - next(); + next(); } -export function requireAdmin(req: Express.Request, res: Response, next: NextFunction) { - if (!req.isAuthenticated()) { - return res.status(401).json({ message: "Authentication required" }); - } +export function requireAdmin(req: Express.Request, _res: Response, next: ExpressNext) { + if (!req.isAuthenticated()) { + return next(new UnauthorizedError("Authentication required")); + } - if (req.user.userType !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } + if (req.user.userType !== "admin") { + return next(new ForbiddenError("Access denied")); + } - next(); + next(); } diff --git a/config/bucket-storage/cloudinary.config.ts b/config/bucket-storage/cloudinary.config.ts index 133c115..7f84bc6 100644 --- a/config/bucket-storage/cloudinary.config.ts +++ b/config/bucket-storage/cloudinary.config.ts @@ -1,10 +1,13 @@ -import { v2 as cloudinary } from 'cloudinary'; +import { v2 as cloudinary } from "cloudinary"; +import { env } from "../env/env-validation"; -cloudinary.config({ - cloud_name: process.env.CLOUDINARY_CLOUD_NAME, - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET, - secure: true -}); +if (env.CLOUDINARY_CLOUD_NAME && env.CLOUDINARY_API_KEY && env.CLOUDINARY_API_SECRET) { + cloudinary.config({ + cloud_name: env.CLOUDINARY_CLOUD_NAME, + api_key: env.CLOUDINARY_API_KEY, + api_secret: env.CLOUDINARY_API_SECRET, + secure: true, + }); +} export default cloudinary; diff --git a/config/bucket-storage/uploadMiddleware.ts b/config/bucket-storage/uploadMiddleware.ts index b79fc7d..98953be 100644 --- a/config/bucket-storage/uploadMiddleware.ts +++ b/config/bucket-storage/uploadMiddleware.ts @@ -1,27 +1,27 @@ -import { CloudinaryStorage } from 'multer-storage-cloudinary'; -import multer from 'multer'; -import cloudinary from './cloudinary.config'; +import { CloudinaryStorage } from "multer-storage-cloudinary"; +import multer from "multer"; +import cloudinary from "./cloudinary.config"; const storage = new CloudinaryStorage({ - cloudinary: cloudinary, - params: async (req, file) => { - return { - folder: 'gallery', - allowed_formats: ['jpg', 'jpeg', 'png', 'gif', 'webp'], - transformation: [{ width: 1000, height: 1000, crop: 'limit' }], - public_id: `${Date.now()}-${file.originalname.split('.')[0]}` - }; - } + cloudinary: cloudinary, + params: async (req, file) => { + return { + folder: "gallery", + allowed_formats: ["jpg", "jpeg", "png", "gif", "webp"], + transformation: [{ width: 1000, height: 1000, crop: "limit" }], + public_id: `${Date.now()}-${file.originalname.split(".")[0]}`, + }; + }, }); const upload = multer({ - storage: storage, - limits: { fileSize: 5 * 1024 * 1024 } // 5MB limit + storage: storage, + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit }); -export const uploadSingleImage = upload.single('image'); - +export const uploadSingleImage = upload.single("image"); + // Use Express.Multer.File type for the file parameter export const getImageUrl = (file: Express.Multer.File) => { - return (file as any).secure_url; // Cloudinary adds secure_url to the file object + return (file as any).secure_url; // Cloudinary adds secure_url to the file object }; diff --git a/config/cors/allowed-origins.ts b/config/cors/allowed-origins.ts index e31e4f2..17be958 100644 --- a/config/cors/allowed-origins.ts +++ b/config/cors/allowed-origins.ts @@ -1,6 +1,22 @@ -const allowOrigins = [ - "http://localhost:3000", - "*" -]; +import { env } from "../env/env-validation"; -export default allowOrigins; +/** + * Allowed CORS origins — derived from env (FRONTEND_URL, CORS_ORIGINS, API_PROD_URL). + * No wildcard when credentials are used; used by getCorsOptions() for origin validation. + */ +export function getAllowedOrigins(): string[] { + const origins: string[] = [env.FRONTEND_URL]; + const extra = env.CORS_ORIGINS; + if (extra) { + origins.push( + ...extra + .split(",") + .map((o) => o.trim()) + .filter(Boolean) + ); + } + if (env.API_PROD_URL) { + origins.push(env.API_PROD_URL); + } + return [...new Set(origins)]; +} diff --git a/config/cors/cors-options.ts b/config/cors/cors-options.ts index affa859..1ea453d 100644 --- a/config/cors/cors-options.ts +++ b/config/cors/cors-options.ts @@ -1,16 +1,40 @@ -import allowOrigins from "./allowed-origins" +/** + * CORS options factory — builds cors() options from env-driven allowed origins. + * Used by the Express app to enforce origin allowlist; no wildcard when credentials are used. + */ +import { getAllowedOrigins } from "./allowed-origins"; +/** HTTP methods allowed for cross-origin requests */ +const CORS_ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; +/** Request headers clients may send */ +const CORS_ALLOWED_HEADERS = ["Content-Type", "Authorization", "X-Request-ID"]; +/** Response headers exposed to the client */ +const CORS_EXPOSED_HEADERS = ["X-Request-ID"]; -const corsOptions = { - origin: (origin: string | undefined, callback: Function) => { - - if ((origin && allowOrigins?.includes(origin)) || !origin) - callback(null, true); - else - callback(new Error("\n\n Origin Not Allowed by Cors")) +/** + * Returns CORS options for the Express cors middleware. + * Origin is validated against getAllowedOrigins(); invalid origin triggers an error (handled by error middleware). + */ +export function getCorsOptions(): import("cors").CorsOptions { + const allowedOrigins = getAllowedOrigins(); + return { + origin: ( + origin: string | undefined, + callback: (err: Error | null, allow?: boolean) => void + ) => { + if (!origin) { + return callback(null, true); + } + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + callback(new Error("Origin not allowed by CORS")); }, + methods: CORS_ALLOWED_METHODS, + allowedHeaders: CORS_ALLOWED_HEADERS, + exposedHeaders: CORS_EXPOSED_HEADERS, credentials: true, - optionSuccessStatus: 200, -}; - -export default corsOptions; + optionsSuccessStatus: 204, + preflightContinue: false, + }; +} diff --git a/config/database/db-connection.ts b/config/database/db-connection.ts index d0351ce..061c3bd 100644 --- a/config/database/db-connection.ts +++ b/config/database/db-connection.ts @@ -1,40 +1,39 @@ import * as schema from "./schema/tables"; import * as relations from "./schema/relations"; -import dotenv from 'dotenv'; import ws from "ws"; -import { Pool as PgPool } from 'pg'; // Local PostgreSQL -import { drizzle as pgDrizzle } from 'drizzle-orm/node-postgres'; -import { Pool as NeonPool, neonConfig } from '@neondatabase/serverless'; // Neon -import { drizzle as neonDrizzle } from 'drizzle-orm/neon-serverless'; +import { Pool as PgPool } from "pg"; // Local PostgreSQL +import { drizzle as pgDrizzle } from "drizzle-orm/node-postgres"; +import { Pool as NeonPool, neonConfig } from "@neondatabase/serverless"; // Neon +import { drizzle as neonDrizzle } from "drizzle-orm/neon-serverless"; +import { env } from "../env/env-validation"; -dotenv.config(); - -if (!process.env.DATABASE_URL_PROD) { +if (!env.DATABASE_URL_PROD && !env.DATABASE_URL) { throw new Error( - "DATABASE_URL_PROD must be set. Did you forget to provision a database?", + "Either DATABASE_URL or DATABASE_URL_PROD must be set. Did you forget to provision a database?" ); } - -const isLocal = (process.env.DATABASE_URL && process.env.DATABASE_URL.includes('localhost')) || - process.env.NODE_ENV === 'development'; +const isLocal = + (env.DATABASE_URL && env.DATABASE_URL.includes("localhost")) || env.NODE_ENV === "development"; let pool, db; -if (isLocal) { +if (env.DATABASE_URL_PROD) { + // Use production DB (Neon serverless) + neonConfig.webSocketConstructor = ws; + pool = new NeonPool({ connectionString: env.DATABASE_URL_PROD }); + db = neonDrizzle(pool, { schema: { ...schema, ...relations } }); +} else if (isLocal && env.DATABASE_URL) { // Local PostgreSQL setup pool = new PgPool({ - connectionString: process.env.DATABASE_URL, + connectionString: env.DATABASE_URL, max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, }); db = pgDrizzle(pool, { schema: { ...schema, ...relations } }); } else { - // Neon serverless setup - neonConfig.webSocketConstructor = ws; - pool = new NeonPool({ connectionString: process.env.DATABASE_URL_PROD }); - db = neonDrizzle(pool, { schema: { ...schema, ...relations } }); + throw new Error("No valid database URL provided"); } export default { pool, db }; diff --git a/config/database/schema/enum.ts b/config/database/schema/enum.ts index 11e1724..d27af74 100644 --- a/config/database/schema/enum.ts +++ b/config/database/schema/enum.ts @@ -1,14 +1,12 @@ // Updated schema with improvements -import { - pgEnum, -} from "drizzle-orm/pg-core"; +import { pgEnum } from "drizzle-orm/pg-core"; // Enums export const userStatus = pgEnum("user_status", ["visitor", "attendant", "admin"]); export const bookingStatus = pgEnum("booking_status", ["pending", "confirmed", "cancelled"]); export default { - userStatus, - bookingStatus, -}; \ No newline at end of file + userStatus, + bookingStatus, +}; diff --git a/config/database/schema/index.ts b/config/database/schema/index.ts index a13bec5..bcc7377 100644 --- a/config/database/schema/index.ts +++ b/config/database/schema/index.ts @@ -49,10 +49,4 @@ import * as schemaTypes from "./schema-types"; // }, // }; - -export { - schema, - relations, - enums, - schemaTypes, -}; +export { schema, relations, enums, schemaTypes }; diff --git a/config/database/schema/relations.ts b/config/database/schema/relations.ts index 0ca6f69..1408f59 100644 --- a/config/database/schema/relations.ts +++ b/config/database/schema/relations.ts @@ -1,98 +1,98 @@ // Updated schema with improvements import { relations } from "drizzle-orm"; -import { bookings, comments, groupMembers, groups, historyContent, postLikes, posts, users } from "./tables"; +import { + bookings, + comments, + groupMembers, + groups, + historyContent, + postLikes, + posts, + users, +} from "./tables"; // import schemas from "./tables"; - // const { bookings, comments, groupMembers, groups, historyContent, postLikes, posts, users } = schemas; - export const usersRelations = relations(users, ({ many }) => ({ - posts: many(posts), - comments: many(comments), - bookings: many(bookings), + posts: many(posts), + comments: many(comments), + bookings: many(bookings), })); - export const historyContentRelations = relations(historyContent, ({ one }) => ({ - author: one(users, { - fields: [historyContent.authorId], - references: [users.id], - }), + author: one(users, { + fields: [historyContent.authorId], + references: [users.id], + }), })); - - export const bookingsRelations = relations(bookings, ({ one }) => ({ - user: one(users, { - fields: [bookings.userId], - references: [users.id], - }), + user: one(users, { + fields: [bookings.userId], + references: [users.id], + }), })); - export const postsRelations = relations(posts, ({ one, many }) => ({ - author: one(users, { - fields: [posts.authorId], - references: [users.id], - }), - comments: many(comments), - likes: many(postLikes), + author: one(users, { + fields: [posts.authorId], + references: [users.id], + }), + comments: many(comments), + likes: many(postLikes), })); - export const commentsRelations = relations(comments, ({ one }) => ({ - post: one(posts, { - fields: [comments.postId], - references: [posts.id], - }), - author: one(users, { - fields: [comments.authorId], - references: [users.id], - }), + post: one(posts, { + fields: [comments.postId], + references: [posts.id], + }), + author: one(users, { + fields: [comments.authorId], + references: [users.id], + }), })); - export const postLikesRelations = relations(postLikes, ({ one }) => ({ - post: one(posts, { - fields: [postLikes.postId], - references: [posts.id], - }), - user: one(users, { - fields: [postLikes.userId], - references: [users.id], - }), + post: one(posts, { + fields: [postLikes.postId], + references: [posts.id], + }), + user: one(users, { + fields: [postLikes.userId], + references: [users.id], + }), })); - export const groupsRelations = relations(groups, ({ one, many }) => ({ - createdBy: one(users, { - fields: [groups.createdById], - references: [users.id], - }), - members: many(groupMembers), + createdBy: one(users, { + fields: [groups.createdById], + references: [users.id], + }), + members: many(groupMembers), })); export const groupMembersRelations = relations(groupMembers, ({ one }) => ({ - group: one(groups, { - fields: [groupMembers.groupId], - references: [groups.id], - }), - user: one(users, { - fields: [groupMembers.userId], - references: [users.id], - }), + group: one(groups, { + fields: [groupMembers.groupId], + references: [groups.id], + }), + user: one(users, { + fields: [groupMembers.userId], + references: [users.id], + }), })); // Schema relation default export export default { - usersRelations, - historyContentRelations, - bookingsRelations, - postsRelations, - commentsRelations, - postLikesRelations, - groupsRelations, - groupMembersRelations, + usersRelations, + historyContentRelations, + bookingsRelations, + postsRelations, + commentsRelations, + postLikesRelations, + groupsRelations, + groupMembersRelations, }; diff --git a/config/database/schema/schema-types.ts b/config/database/schema/schema-types.ts index d4efaa0..9a9cf7c 100644 --- a/config/database/schema/schema-types.ts +++ b/config/database/schema/schema-types.ts @@ -4,7 +4,18 @@ import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; import schemas from "./tables"; -const { bookings, comments, groupMembers, groups, historyContent, postLikes, posts, users, galleryItems, contactMessages } = schemas; +const { + bookings, + comments, + groupMembers, + groups, + historyContent, + postLikes, + posts, + users, + galleryItems, + contactMessages, +} = schemas; // Schema validations (remain the same as your original) export const insertUserSchema = createInsertSchema(users, { @@ -15,19 +26,19 @@ export const insertUserSchema = createInsertSchema(users, { }).omit({ id: true, createdAt: true, - updatedAt: true + updatedAt: true, }); export const insertHistoryContentSchema = createInsertSchema(historyContent).omit({ id: true, createdAt: true, - updatedAt: true + updatedAt: true, }); export const insertGalleryItemSchema = createInsertSchema(galleryItems).omit({ id: true, createdAt: true, - updatedAt: true + updatedAt: true, }); export const insertBookingSchema = createInsertSchema(bookings) @@ -39,19 +50,19 @@ export const insertBookingSchema = createInsertSchema(bookings) id: true, status: true, createdAt: true, - updatedAt: true + updatedAt: true, }); export const insertPostSchema = createInsertSchema(posts).omit({ id: true, createdAt: true, - updatedAt: true + updatedAt: true, }); export const insertCommentSchema = createInsertSchema(comments).omit({ id: true, createdAt: true, - updatedAt: true + updatedAt: true, }); export const insertContactMessageSchema = createInsertSchema(contactMessages) @@ -62,12 +73,12 @@ export const insertContactMessageSchema = createInsertSchema(contactMessages) .omit({ id: true, isRead: true, - createdAt: true + createdAt: true, }); const postLikeSchema = createInsertSchema(postLikes).omit({ id: true, - createdAt: true + createdAt: true, }); export const insertPostLikeSchema = postLikeSchema.extend({ @@ -75,20 +86,18 @@ export const insertPostLikeSchema = postLikeSchema.extend({ userId: z.string().uuid(), }); -export const insertGroupSchema = createInsertSchema(groups) - .omit({ - id: true, - isAttendantOnly: true, - createdAt: true, - updatedAt: true - }); +export const insertGroupSchema = createInsertSchema(groups).omit({ + id: true, + isAttendantOnly: true, + createdAt: true, + updatedAt: true, +}); export const insertGroupMemberSchema = createInsertSchema(groupMembers).omit({ id: true, - createdAt: true + createdAt: true, }); - // Types for TypeScript export type User = typeof users.$inferSelect; export type InsertUser = z.infer; diff --git a/config/database/schema/tables.ts b/config/database/schema/tables.ts index 4ff3b2e..d7579b5 100644 --- a/config/database/schema/tables.ts +++ b/config/database/schema/tables.ts @@ -1,167 +1,174 @@ // Updated schema with improvements -import { - pgTable, - text, - boolean, - timestamp, - uuid, - unique -} from "drizzle-orm/pg-core"; +import { pgTable, text, boolean, timestamp, uuid, unique } from "drizzle-orm/pg-core"; import { z } from "zod"; import { bookingStatus, userStatus } from "./enum"; - // Reusable email schema const emailSchema = z.string().email("Must provide a valid email"); // Users table export const users = pgTable("users", { - id: uuid("id").primaryKey().notNull().defaultRandom(), - username: text("username").notNull().unique(), - password: text("password").notNull(), - email: text("email").notNull().unique(), - fullName: text("full_name").notNull(), - userType: userStatus("role").notNull().default("visitor"), - profileImage: text("profile_image"), - bio: text("bio"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), - deletedAt: timestamp("deleted_at", { withTimezone: true }), + id: uuid("id").primaryKey().notNull().defaultRandom(), + username: text("username").notNull().unique(), + password: text("password").notNull(), + email: text("email").notNull().unique(), + fullName: text("full_name").notNull(), + userType: userStatus("role").notNull().default("visitor"), + profileImage: text("profile_image"), + bio: text("bio"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), }); - // History content table export const historyContent = pgTable("history_content", { - id: uuid("id").primaryKey().notNull().defaultRandom(), - title: text("title").notNull(), - slug: text("slug").notNull().unique(), - content: text("content").notNull(), - metaDescription: text("meta_description").notNull(), - keywords: text("keywords").notNull(), - imageUrl: text("image_url"), - authorId: uuid("author_id").references(() => users.id, { onDelete: "set null", onUpdate: "cascade" }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), - deletedAt: timestamp("deleted_at", { withTimezone: true }), + id: uuid("id").primaryKey().notNull().defaultRandom(), + title: text("title").notNull(), + slug: text("slug").notNull().unique(), + content: text("content").notNull(), + metaDescription: text("meta_description").notNull(), + keywords: text("keywords").notNull(), + imageUrl: text("image_url"), + authorId: uuid("author_id").references(() => users.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), }); - // Gallery items table export const galleryItems = pgTable("gallery_items", { - id: uuid("id").primaryKey().notNull().defaultRandom(), - title: text("title").notNull(), - description: text("description").notNull(), - imageUrl: text("image_url").notNull(), - altText: text("alt_text"), - category: text("category").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + id: uuid("id").primaryKey().notNull().defaultRandom(), + title: text("title").notNull(), + description: text("description").notNull(), + imageUrl: text("image_url").notNull(), + altText: text("alt_text"), + category: text("category").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); - // Bookings table export const bookings = pgTable("bookings", { - id: uuid("id").defaultRandom().primaryKey().notNull(), - userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }), - fullName: text("full_name"), - email: text("email"), - phone: text("phone"), - visitDate: timestamp("visit_date", { mode: "string", withTimezone: true }).notNull(), - groupSize: text("group_size").notNull(), - tourType: text("tour_type").notNull(), - specialRequests: text("special_requests"), - status: bookingStatus("status").notNull().default("pending"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + id: uuid("id").defaultRandom().primaryKey().notNull(), + userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }), + fullName: text("full_name"), + email: text("email"), + phone: text("phone"), + visitDate: timestamp("visit_date", { mode: "string", withTimezone: true }).notNull(), + groupSize: text("group_size").notNull(), + tourType: text("tour_type").notNull(), + specialRequests: text("special_requests"), + status: bookingStatus("status").notNull().default("pending"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); - // Posts table export const posts = pgTable("posts", { - id: uuid("id").defaultRandom().primaryKey().notNull(), - authorId: uuid("author_id").references(() => users.id, { onDelete: "cascade" }).notNull(), - title: text("title").notNull(), - slug: text("slug").notNull().unique(), - content: text("content").notNull(), - isAttendantOnly: boolean("is_attendant_only").default(false), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), - deletedAt: timestamp("deleted_at", { withTimezone: true }), + id: uuid("id").defaultRandom().primaryKey().notNull(), + authorId: uuid("author_id") + .references(() => users.id, { onDelete: "cascade" }) + .notNull(), + title: text("title").notNull(), + slug: text("slug").notNull().unique(), + content: text("content").notNull(), + isAttendantOnly: boolean("is_attendant_only").default(false), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), }); - // Comments table export const comments = pgTable("comments", { - id: uuid("id").defaultRandom().primaryKey().notNull(), - postId: uuid("post_id").references(() => posts.id, { onDelete: "cascade" }).notNull(), - authorId: uuid("author_id").references(() => users.id, { onDelete: "cascade" }).notNull(), - content: text("content").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + id: uuid("id").defaultRandom().primaryKey().notNull(), + postId: uuid("post_id") + .references(() => posts.id, { onDelete: "cascade" }) + .notNull(), + authorId: uuid("author_id") + .references(() => users.id, { onDelete: "cascade" }) + .notNull(), + content: text("content").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); - // Post likes table -export const postLikes = pgTable("post_likes", { +export const postLikes = pgTable( + "post_likes", + { id: uuid("id").defaultRandom().primaryKey().notNull(), - postId: uuid("post_id").references(() => posts.id, { onDelete: "cascade" }).notNull(), - userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }).notNull(), + postId: uuid("post_id") + .references(() => posts.id, { onDelete: "cascade" }) + .notNull(), + userId: uuid("user_id") + .references(() => users.id, { onDelete: "cascade" }) + .notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), -}, (table) => ({ + }, + (table) => ({ uniquePostUser: unique().on(table.postId, table.userId), -})); - + }) +); // Groups table export const groups = pgTable("groups", { - id: uuid("id").defaultRandom().primaryKey().notNull(), - name: text("name").notNull(), - description: text("description").notNull(), - slug: text("slug").notNull().unique(), - isAttendantOnly: boolean("is_attendant_only").default(false), - createdById: uuid("created_by_id").references(() => users.id, { onDelete: "cascade" }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + id: uuid("id").defaultRandom().primaryKey().notNull(), + name: text("name").notNull(), + description: text("description").notNull(), + slug: text("slug").notNull().unique(), + isAttendantOnly: boolean("is_attendant_only").default(false), + createdById: uuid("created_by_id") + .references(() => users.id, { onDelete: "cascade" }) + .notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); - // Group members table -export const groupMembers = pgTable("group_members", { +export const groupMembers = pgTable( + "group_members", + { id: uuid("id").defaultRandom().primaryKey().notNull(), - groupId: uuid("group_id").references(() => groups.id, { onDelete: "cascade" }).notNull(), - userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }).notNull(), + groupId: uuid("group_id") + .references(() => groups.id, { onDelete: "cascade" }) + .notNull(), + userId: uuid("user_id") + .references(() => users.id, { onDelete: "cascade" }) + .notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), -}, (table) => ({ + }, + (table) => ({ uniqueGroupUser: unique().on(table.groupId, table.userId), -})); - + }) +); // Contact messages export const contactMessages = pgTable("contact_messages", { - id: uuid("id").defaultRandom().primaryKey().notNull(), - fullName: text("full_name").notNull(), - email: text("email").notNull(), - subject: text("subject").notNull(), - message: text("message").notNull(), - isRead: boolean("is_read").default(false), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + id: uuid("id").defaultRandom().primaryKey().notNull(), + fullName: text("full_name").notNull(), + email: text("email").notNull(), + subject: text("subject").notNull(), + message: text("message").notNull(), + isRead: boolean("is_read").default(false), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }); - - - // Schema export default { - users, - historyContent, - galleryItems, - bookings, - posts, - comments, - postLikes, - groups, - groupMembers, - contactMessages, + users, + historyContent, + galleryItems, + bookings, + posts, + comments, + postLikes, + groups, + groupMembers, + contactMessages, }; // export type DatabaseSchema = typeof schema; diff --git a/config/database/storage-interface.ts b/config/database/storage-interface.ts index 9bf4e8a..afa268c 100644 --- a/config/database/storage-interface.ts +++ b/config/database/storage-interface.ts @@ -6,10 +6,9 @@ import { type Booking, type Post, type Comment, - type ContactMessage + type ContactMessage, } from "./schema/schema-types"; - export interface IStorage { // User operations getUser(id: string): Promise; @@ -21,64 +20,88 @@ export interface IStorage { // History content operations getHistoryContentById(id: string): Promise; getHistoryContentBySlug(slug: string): Promise; - getAllHistoryContent(): Promise; + getAllHistoryContent(): Promise< + | HistoryContent[] + | { + data: HistoryContent[]; + total: number; + hasMore: boolean; + } + >; createHistoryContent(data: Partial): Promise; updateHistoryContent(id: string, data: Partial): Promise; // Gallery operations getGalleryItemById(id: string): Promise; - getAllGalleryItems(): Promise; - getGalleryItemsByCategory(category: string): Promise; + getAllGalleryItems(): Promise< + | GalleryItem[] + | { + data: GalleryItem[]; + total: number; + hasMore: boolean; + } + >; + getGalleryItemsByCategory(category: string): Promise< + | GalleryItem[] + | { + data: GalleryItem[]; + total: number; + hasMore: boolean; + } + >; createGalleryItem(data: Partial): Promise; // Booking operations getBookingById(id: string): Promise; - getBookingsByUserId(userId: string): Promise; - getAllBookings(status?: string): Promise; + getBookingsByUserId(userId: string): Promise< + | Booking[] + | { + data: Booking[]; + total: number; + hasMore: boolean; + } + >; + getAllBookings(status?: string): Promise< + | Booking[] + | { + data: Booking[]; + total: number; + hasMore: boolean; + } + >; createBooking(data: Partial): Promise; updateBookingStatus(id: string, status: string): Promise; // Forum operations getPostById(id: string): Promise; - getAllPosts(isAttendantOnly?: boolean): Promise; + getAllPosts(isAttendantOnly?: boolean): Promise< + | Post[] + | { + data: Post[]; + total: number; + hasMore: boolean; + } + >; createPost(data: Partial): Promise; - getCommentsByPostId(postId: string): Promise; + getCommentsByPostId(postId: string): Promise< + | Comment[] + | { + data: Comment[]; + total: number; + hasMore: boolean; + } + >; createComment(data: any): Promise; // Contact message operations - getAllContactMessages(): Promise; + getAllContactMessages(): Promise< + | ContactMessage[] + | { + data: ContactMessage[]; + total: number; + hasMore: boolean; + } + >; createContactMessage(data: Partial): Promise; markContactMessageAsRead(id: string): Promise; getUnreadContactMessagesCount(): Promise; diff --git a/config/env/env-validation.ts b/config/env/env-validation.ts new file mode 100644 index 0000000..8a8a06e --- /dev/null +++ b/config/env/env-validation.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; +// Use namespace import so compiled CJS works (dotenv has no .default in CommonJS) +import * as dotenv from "dotenv"; + +const dotenvModule = dotenv as { config?: () => void }; +if (typeof dotenvModule.config === "function") { + dotenvModule.config(); +} + +/** + * Environment variable schema validation + * This ensures all required environment variables are present and valid + */ +const envSchema = z + .object({ + // Node Environment + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + + // Server Configuration + PORT: z + .string() + .regex(/^\d+$/, "PORT must be a valid number") + .transform((val) => parseInt(val, 10)) + .pipe(z.number().int().min(1).max(65535)) + .default("5001"), + + // Database Configuration + DATABASE_URL: z + .string() + .url("DATABASE_URL must be a valid PostgreSQL connection string") + .optional(), + + DATABASE_URL_PROD: z + .string() + .url("DATABASE_URL_PROD must be a valid PostgreSQL connection string") + .optional(), + + // Session Configuration + SESSION_SECRET: z + .string() + .min(32, "SESSION_SECRET must be at least 32 characters long") + .default("museum_secret_development_only_change_in_production"), + + // CORS Configuration + FRONTEND_URL: z + .string() + .url("FRONTEND_URL must be a valid URL") + .default("http://localhost:3000"), + + API_PROD_URL: z.string().url("API_PROD_URL must be a valid URL").optional(), + + // Optional comma-separated list of extra CORS origins + CORS_ORIGINS: z.string().optional(), + + // Cloudinary Configuration (optional, only needed if using file uploads) + CLOUDINARY_CLOUD_NAME: z + .string() + .min(1, "CLOUDINARY_CLOUD_NAME is required if using Cloudinary") + .optional(), + + CLOUDINARY_API_KEY: z + .string() + .min(1, "CLOUDINARY_API_KEY is required if using Cloudinary") + .optional(), + + CLOUDINARY_API_SECRET: z + .string() + .min(1, "CLOUDINARY_API_SECRET is required if using Cloudinary") + .optional(), + }) + .refine( + (data) => { + // At least one database URL must be provided + return !!(data.DATABASE_URL || data.DATABASE_URL_PROD); + }, + { + message: "Either DATABASE_URL or DATABASE_URL_PROD must be provided", + path: ["DATABASE_URL"], + } + ) + .refine( + (data) => { + // In production, API_PROD_URL is required + if (data.NODE_ENV === "production" && !data.API_PROD_URL) { + return false; + } + return true; + }, + { + message: "API_PROD_URL is required in production environment", + path: ["API_PROD_URL"], + } + ) + .refine( + (data) => { + // If any Cloudinary env var is set, all must be set + const cloudinaryVars = [ + data.CLOUDINARY_CLOUD_NAME, + data.CLOUDINARY_API_KEY, + data.CLOUDINARY_API_SECRET, + ]; + const hasSome = cloudinaryVars.some((v) => v !== undefined); + const hasAll = cloudinaryVars.every((v) => v !== undefined); + return !hasSome || hasAll; + }, + { + message: "All Cloudinary environment variables must be provided together", + path: ["CLOUDINARY_CLOUD_NAME"], + } + ); + +/** + * Validated environment variables + * This will throw an error at startup if any required env vars are missing or invalid + */ +export type Env = z.infer; + +let validatedEnv: Env; + +try { + validatedEnv = envSchema.parse(process.env); +} catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors + .map((err) => { + const path = err.path.join("."); + return ` - ${path}: ${err.message}`; + }) + .join("\n"); + + console.error("\n❌ Environment variable validation failed:\n"); + console.error(errorMessages); + console.error( + "\nPlease check your .env file and ensure all required variables are set correctly.\n" + ); + process.exit(1); + } + throw error; +} + +/** + * Get validated environment variables + * Use this instead of accessing process.env directly + */ +export const env = validatedEnv; + +/** + * Helper to check if we're in production + */ +export const isProduction = env.NODE_ENV === "production"; + +/** + * Helper to check if we're in development + */ +export const isDevelopment = env.NODE_ENV === "development"; + +/** + * Helper to check if we're in test + */ +export const isTest = env.NODE_ENV === "test"; diff --git a/config/logger/logger-config.ts b/config/logger/logger-config.ts new file mode 100644 index 0000000..b6a347e --- /dev/null +++ b/config/logger/logger-config.ts @@ -0,0 +1,112 @@ +import path from "path"; +import fs from "fs"; +import dotenv from "dotenv"; + +dotenv.config(); + +const NODE_ENV = process.env.NODE_ENV || "development"; + +/** Minimal logger interface used across the app */ +export interface AppLogger { + debug: (message: string, meta?: Record) => void; + info: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + error: (message: string, meta?: Record) => void; +} + +function createConsoleLogger(): AppLogger { + const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }; + const minLevel = NODE_ENV === "production" ? "info" : "debug"; + const min = levelOrder[minLevel as keyof typeof levelOrder] ?? 0; + const ts = () => new Date().toISOString(); + return { + debug: (msg, meta) => { + if (min <= 0) console.debug(ts(), "DEBUG", msg, meta ?? ""); + }, + info: (msg, meta) => { + if (min <= 1) console.info(ts(), "INFO", msg, meta ?? ""); + }, + warn: (msg, meta) => { + if (min <= 2) console.warn(ts(), "WARN", msg, meta ?? ""); + }, + error: (msg, meta) => { + if (min <= 3) console.error(ts(), "ERROR", msg, meta ?? ""); + }, + }; +} + +function createWinstonLogger(): AppLogger { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const winston = require("winston"); + const logsDir = path.join(process.cwd(), "logs"); + if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true }); + + const logFormat = winston.format.combine( + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), + winston.format.errors({ stack: true }), + winston.format.splat(), + winston.format.json() + ); + + const consoleFormat = winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), + winston.format.printf( + (info: { timestamp?: string; level?: string; message?: string; [k: string]: unknown }) => { + const { timestamp, level, message, ...rest } = info; + let msg = `${timestamp ?? ""} [${level ?? ""}]: ${message ?? ""}`; + if (Object.keys(rest).length > 0) msg += ` ${JSON.stringify(rest)}`; + return msg; + } + ) + ); + + const w = winston.createLogger({ + level: NODE_ENV === "production" ? "info" : "debug", + format: logFormat, + defaultMeta: { service: "museum-api" }, + transports: [ + new winston.transports.File({ + filename: path.join(logsDir, "combined.log"), + maxsize: 5242880, + maxFiles: 5, + }), + new winston.transports.File({ + filename: path.join(logsDir, "error.log"), + level: "error", + maxsize: 5242880, + maxFiles: 5, + }), + ], + exceptionHandlers: [ + new winston.transports.File({ filename: path.join(logsDir, "exceptions.log") }), + ], + rejectionHandlers: [ + new winston.transports.File({ filename: path.join(logsDir, "rejections.log") }), + ], + }); + + w.add( + new winston.transports.Console({ + format: NODE_ENV === "production" ? logFormat : consoleFormat, + }) + ); + + return { + debug: (msg, meta) => w.debug(msg, meta), + info: (msg, meta) => w.info(msg, meta), + warn: (msg, meta) => w.warn(msg, meta), + error: (msg, meta) => w.error(msg, meta), + }; +} + +let logger: AppLogger; +try { + require.resolve("winston"); + logger = createWinstonLogger(); +} catch { + logger = createConsoleLogger(); +} + +export { logger }; +export default logger; diff --git a/config/openapi/.prettierignore b/config/openapi/.prettierignore new file mode 100644 index 0000000..64b18fe --- /dev/null +++ b/config/openapi/.prettierignore @@ -0,0 +1,2 @@ +# Ignore entire OpenAPI spec folder (manual formatting; braces must not be stripped) +* diff --git a/config/openapi/spec.ts b/config/openapi/spec.ts new file mode 100644 index 0000000..4dc74ce --- /dev/null +++ b/config/openapi/spec.ts @@ -0,0 +1,5335 @@ +// /** +// * OpenAPI 3.0.3 spec for Museum Management REST API. +// * Served at GET /api-docs (Swagger UI) and GET /api-docs.json (raw JSON). +// * Base path: /api/v1 +// */ + +// const API_VERSION = process.env.npm_package_version ?? "1.0.0"; + +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// const spec: Record = { +// openapi: "3.0.3", +// info: { +// title: "Museum Management REST API", +// version: API_VERSION, +// description: +// "REST API for a digital museum platform: content management, session-based auth, bookings, forum, gallery, and real-time features.\n\n**Security:** Authenticated endpoints use session-based auth via `connect.sid` cookie. Call `/login` or `/register` with credentials to obtain a session. Use **Try it out** in Swagger UI with credentials enabled to test protected endpoints.\n\n**CRUD by collection:** History and Gallery support full CRUD (GET list, GET by id, POST, PUT, DELETE). Post likes use POST (like) and DELETE (unlike) only—no PUT, since a like is binary. Contact form messages are immutable from the public API (POST only); GET list, GET unread count, and PATCH read are available. Admin-only operations (see Admin tag) include GET/DELETE contact messages and marking as read.", +// contact: { +// name: "API support", +// }, +// license: { +// name: "MIT", +// url: "https://opensource.org/licenses/MIT", +// }, +// }, +// servers: [ +// { url: "/api/v1", description: "API v1 base path" }, +// ], +// tags: [ +// { name: "Health", description: "Liveness and readiness probes" }, +// { name: "Auth", description: "Registration, login, logout, current user" }, +// { name: "Users", description: "User CRUD and profile" }, +// { name: "History", description: "History content CRUD" }, +// { name: "Gallery", description: "Gallery items" }, +// { name: "Bookings", description: "Visit bookings" }, +// { name: "Forum", description: "Posts and comments" }, +// { name: "Post likes", description: "Like/unlike posts" }, +// { name: "Contact", description: "Contact form and messages" }, +// { name: "Admin", description: "Admin-only operations: contact messages list, get by ID, mark read, delete" }, +// ], +// paths: { +// // ---------- Health ---------- +// "/health": { +// get: { +// summary: "Health check (readiness)", +// description: +// "Returns service and dependency status. Use for load balancer readiness. Returns 503 if DB is down.", +// tags: ["Health"], +// operationId: "getHealth", +// responses: { +// "200": { +// description: "Healthy", +// content: { +// "application/json": { +// schema: { +// type: "object", +// required: ["success", "data"], +// properties: { +// success: { type: "boolean", example: true }, +// data: { +// type: "object", +// properties: { +// status: { type: "string", enum: ["healthy", "unhealthy"] }, +// timestamp: { type: "string", format: "date-time" }, +// version: { type: "string" }, +// checks: { +// type: "object", +// additionalProperties: { type: "string" }, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// "503": { +// description: "Unhealthy (e.g. database down)", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// }, +// "/live": { +// get: { +// summary: "Liveness probe", +// description: +// "Returns 200 if the process is running. Does not check dependencies. Use for Kubernetes liveness.", +// tags: ["Health"], +// operationId: "getLive", +// responses: { +// "200": { +// description: "Process is alive", +// content: { +// "application/json": { +// schema: { +// type: "object", +// required: ["success", "data"], +// properties: { +// success: { type: "boolean", example: true }, +// data: { +// type: "object", +// properties: { +// status: { type: "string", example: "alive" }, +// timestamp: { type: "string", format: "date-time" }, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// "/ready": { +// get: { +// summary: "Readiness probe", +// description: "Same as /health. Returns 200 when service and DB are ready to accept traffic.", +// tags: ["Health"], +// operationId: "getReady", +// responses: { +// "200": { description: "Ready" }, +// "503": { +// description: "Not ready", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// }, + +// // ---------- Auth ---------- +// "/register": { +// post: { +// summary: "Register and log in", +// description: "Creates a new user and logs them in. Returns user (no password).", +// tags: ["Auth"], +// operationId: "register", +// requestBody: { +// required: true, +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/RegisterRequest" }, +// }, +// }, +// }, +// responses: { +// "201": { +// description: "User created and logged in", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/UserPublic" }, +// }, +// }, +// }, +// "400": { +// description: "Username or email already exists", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Message" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// } +// }, +// "/login": { +// post: { +// summary: "Log in", +// description: "Authenticates with username/email and password. Sets session cookie.", +// tags: ["Auth"], +// operationId: "login", +// requestBody: { +// required: true, +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/LoginRequest" }, +// }, +// }, +// }, +// responses: { +// "200": { +// description: "Logged in", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/UserPublic" }, +// }, +// }, +// }, +// "401": { +// description: "Invalid credentials", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Message" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// } +// }, +// "/logout": { +// post: { +// summary: "Log out", +// description: "Destroys the current session.", +// tags: ["Auth"], +// operationId: "logout", +// security: [{ cookieAuth: [] }], +// responses: { +// "200": { description: "Logged out" }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// } +// }, +// "/current_user": { +// get: { +// summary: "Get current user", +// description: "Returns the authenticated user (no password).", +// tags: ["Auth"], +// operationId: "getCurrentUser", +// security: [{ cookieAuth: [] }], +// responses: { +// "200": { +// description: "Current user", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/UserPublic" }, +// }, +// }, +// }, +// "401": { +// description: "Not authenticated", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// }, +// // ---------- Users ---------- +// "/users": { +// get: { +// summary: "List users", +// description: "Returns paginated users. Optional filter by role.", +// tags: ["Users"], +// operationId: "getAllUsers", +// parameters: [ +// { $ref: "#/components/parameters/PageQuery" }, +// { $ref: "#/components/parameters/PageSizeQuery" }, +// { +// name: "role", +// in: "query", +// description: "Filter by user role", +// schema: { type: "string", enum: ["visitor", "attendant", "admin"] }, +// }, +// ], +// responses: { +// "200": { +// description: "List of users", +// content: { +// "application/json": { +// schema: { +// type: "array", +// items: { $ref: "#/components/schemas/User" }, +// }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// post: { +// summary: "Create user", +// description: "Creates a new user (admin/registration use).", +// tags: ["Users"], +// operationId: "createUser", +// requestBody: { +// required: true, +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/CreateUserRequest" }, +// }, +// }, +// }, +// responses: { +// "201": { +// description: "User created", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/User" }, +// }, +// }, +// }, +// "400": { +// description: "Validation error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/ValidationErrors" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// }, +// "/users/{id}": { +// get: { +// summary: "Get user by ID or username", +// description: +// "Path parameter may be user UUID or username (route order determines which handler runs).", +// tags: ["Users"], +// operationId: "getUserById", +// parameters: [ +// { +// name: "id", +// in: "path", +// required: true, +// description: "User UUID or username", +// schema: { type: "string" }, +// }, +// ], +// responses: { +// "200": { +// description: "User", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/User" }, +// }, +// }, +// }, +// "404": { +// description: "User not found", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// put: { +// summary: "Update user", +// tags: ["Users"], +// operationId: "updateUser", +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// requestBody: { +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/UpdateUserRequest" }, +// }, +// }, +// }, +// responses: { +// "200": { +// description: "Updated user", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/User" }, +// }, +// }, +// }, +// "400": { +// description: "Validation error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/ValidationErrors" }, +// }, +// }, +// }, +// "404": { +// description: "User not found", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// delete: { +// summary: "Delete user by ID", +// tags: ["Users"], +// operationId: "deleteUserById", +// parameters: [{ $ref: "#/components/parameters/IdPath" }], +// responses: { +// "204": { description: "User deleted" }, +// "404": { +// description: "User not found", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// }, +// // ---------- History ---------- +// "/histories": { +// get: { +// summary: "List history content", +// description: "Returns paginated history content.", +// tags: ["History"], +// operationId: "getAllHistoryContent", +// parameters: [ +// { $ref: "#/components/parameters/PageQuery" }, +// { $ref: "#/components/parameters/PageSizeQuery" }, +// ], +// responses: { +// "200": { +// description: "List of history content", +// content: { +// "application/json": { +// schema: { +// type: "array", +// items: { $ref: "#/components/schemas/HistoryContent" }, +// }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// post: { +// summary: "Create history content", +// description: "Creates new history content. Protected by session auth in production.", +// tags: ["History"], +// operationId: "createHistoryContent", +// security: [{ cookieAuth: [] }], +// requestBody: { +// required: true, +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/CreateHistoryContentRequest" }, +// }, +// }, +// }, +// responses: { +// "201": { +// description: "History content created", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/HistoryContent" }, +// }, +// }, +// }, +// "401": { +// description: "Authentication required", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "400": { +// description: "Validation error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/ValidationErrors" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// "/histories/{id}": { +// get: { +// summary: "Get history content by ID or slug", +// description: +// "Path parameter may be UUID or slug. Route order on server determines which resolver runs.", +// tags: ["History"], +// operationId: "getHistoryContentById", +// parameters: [ +// { +// name: "id", +// in: "path", +// required: true, +// description: "History content UUID or slug", +// schema: { type: "string" }, +// }, +// ], +// responses: { +// "200": { +// description: "History content", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/HistoryContent" }, +// }, +// }, +// }, +// "404": { +// description: "Not found", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// put: { +// summary: "Update history content", +// description: "Updates existing history content by UUID. Protected by session auth in production.", +// tags: ["History"], +// operationId: "updateHistoryContent", +// security: [{ cookieAuth: [] }], +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// requestBody: { +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/UpdateHistoryContentRequest" }, +// }, +// }, +// }, +// responses: { +// "200": { +// description: "Updated history content", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/HistoryContent" }, +// }, +// }, +// }, +// "400": { +// description: "Validation error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/ValidationErrors" }, +// }, +// }, +// }, +// "401": { +// description: "Authentication required", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "404": { +// description: "Not found", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// delete: { +// summary: "Delete history content", +// description: "Soft-deletes history content by UUID. Protected by session auth in production.", +// tags: ["History"], +// operationId: "deleteHistoryContent", +// security: [{ cookieAuth: [] }], +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// responses: { +// "200": { +// description: "Deleted", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Message" }, +// }, +// }, +// }, +// "401": { +// description: "Authentication required", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "404": { +// description: "Not found", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// }, + +// // ---------- Gallery ---------- +// // "/galleries": { +// // get: { +// // summary: "List gallery items", +// // description: "Returns paginated gallery items. Optional query filter by category.", +// // tags: ["Gallery"], +// // operationId: "getAllGalleryItems", +// // parameters: [ +// // { $ref: "#/components/parameters/PageQuery" }, +// // { $ref: "#/components/parameters/PageSizeQuery" }, +// // { +// // name: "category", +// // in: "query", +// // schema: { type: "string" }, +// // }, +// // ], +// // responses: { +// // "200": { +// // description: "List of gallery items", +// // content: { +// // "application/json": { +// // schema: { +// // type: "array", +// // items: { $ref: "#/components/schemas/GalleryItem" }, +// // }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // post: { +// // summary: "Create gallery item", +// // description: "Requires multipart/form-data with image file and fields. Protected by session auth in production.", +// // tags: ["Gallery"], +// // operationId: "createGalleryItem", +// // security: [{ cookieAuth: [] }], +// // requestBody: { +// // required: true, +// // content: { +// // "multipart/form-data": { +// // schema: { +// // type: "object", +// // required: ["title", "description", "category"], +// // properties: { +// // title: { type: "string" }, +// // description: { type: "string" }, +// // imageUrl: { type: "string", description: "Optional if file uploaded" }, +// // altText: { type: "string" }, +// // category: { type: "string" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // responses: { +// // "201": { +// // description: "Gallery item created", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/GalleryItem" }, +// // }, +// // }, +// // }, +// // "400": { +// // description: "Image file required or validation error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "401": { +// // description: "Authentication required", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, + +// // "/galleries/categories/{category}": { +// // get: { +// // summary: "List gallery items by category", +// // tags: ["Gallery"], +// // operationId: "getGalleryItemsByCategory", +// // parameters: [ +// // { +// // name: "category", +// // in: "path", +// // required: true, +// // schema: { type: "string" }, +// // }, +// // { $ref: "#/components/parameters/PageQuery" }, +// // { $ref: "#/components/parameters/PageSizeQuery" }, +// // ], +// // responses: { +// // "200": { +// // description: "List of gallery items", +// // content: { +// // "application/json": { +// // schema: { +// // type: "array", +// // items: { $ref: "#/components/schemas/GalleryItem" }, +// // }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, + +// // "/galleries/{id}": { +// // get: { +// // summary: "Get gallery item by ID", +// // tags: ["Gallery"], +// // operationId: "getGalleryItemById", +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // responses: { +// // "200": { +// // description: "Gallery item", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/GalleryItem" }, +// // }, +// // }, +// // }, +// // "404": { +// // description: "Not found", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // put: { +// // summary: "Update gallery item", +// // description: "Updates an existing gallery item by ID. Protected by session auth in production.", +// // tags: ["Gallery"], +// // operationId: "updateGalleryItem", +// // security: [{ cookieAuth: [] }], +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // requestBody: { +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/UpdateGalleryItemRequest" }, +// // }, +// // "multipart/form-data": { +// // schema: { +// // type: "object", +// // properties: { +// // title: { type: "string" }, +// // description: { type: "string" }, +// // imageUrl: { type: "string" }, +// // altText: { type: "string" }, +// // category: { type: "string" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // responses: { +// // "200": { +// // description: "Updated gallery item", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/GalleryItem" }, +// // }, +// // }, +// // }, +// // "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationErrors" } } }}, +// // "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "404": { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // }, +// // }, +// // delete: { +// // summary: "Delete gallery item", +// // description: "Deletes a gallery item by ID. Protected by session auth in production.", +// // tags: ["Gallery"], +// // operationId: "deleteGalleryItem", +// // security: [{ cookieAuth: [] }], +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // responses: { +// // "204": { description: "Gallery item deleted" }, +// // "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "404": { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // }, +// // }, +// // }, +// // }, +// // // ---------- Bookings ---------- +// // "/bookings": { +// // get: { +// // summary: "List bookings", +// // description: +// // "Authenticated. Visitors see only their bookings; attendants/admins see all. Optional filter by status (query).", +// // tags: ["Bookings"], +// // operationId: "getAllBookings", +// // security: [{ cookieAuth: [] }], +// // parameters: [ +// // { $ref: "#/components/parameters/PageQuery" }, +// // { $ref: "#/components/parameters/PageSizeQuery" }, +// // { +// // name: "status", +// // in: "query", +// // schema: { type: "string", enum: ["pending", "confirmed", "cancelled"] }, +// // }, +// // ], +// // responses: { +// // "200": { +// // description: "List of bookings", +// // content: { +// // "application/json": { +// // schema: { +// // type: "array", +// // items: { $ref: "#/components/schemas/Booking" }, +// // }, +// // }, +// // }, +// // }, +// // "401": { +// // description: "Authentication required", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // post: { +// // summary: "Create booking", +// // description: "Creates a booking. If authenticated, userId is set from session. Sends WebSocket event.", +// // tags: ["Bookings"], +// // operationId: "createBooking", +// // requestBody: { +// // required: true, +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/CreateBookingRequest" }, +// // }, +// // }, +// // }, +// // responses: { +// // "201": { +// // description: "Booking created", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Booking" }, +// // }, +// // }, +// // }, +// // "400": { +// // description: "Validation error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/ValidationErrors" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // } +// // } +// // }, +// // "/bookings/{id}": { +// // get: { +// // summary: "Get booking by ID", +// // tags: ["Bookings"], +// // operationId: "getBookingById", +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // responses: { +// // "200": { +// // description: "Booking", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Booking" }, +// // }, +// // }, +// // }, +// // "404": { +// // description: "Not found", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // put: { +// // summary: "Update booking", +// // description: "Full update of a booking by ID. Authenticated; visitors may only update their own.", +// // tags: ["Bookings"], +// // operationId: "updateBooking", +// // security: [{ cookieAuth: [] }], +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // requestBody: { +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/UpdateBookingRequest" }, +// // }, +// // }, +// // }, +// // responses: { +// // "200": { +// // description: "Updated booking", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Booking" }, +// // }, +// // }, +// // }, +// // "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationErrors" } } }}, +// // "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "403": { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "404": { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // }, +// // }}, +// // delete: { +// // summary: "Delete booking", +// // description: "Deletes a booking by ID. Authenticated; visitors may only delete their own.", +// // tags: ["Bookings"], +// // operationId: "deleteBooking", +// // security: [{ cookieAuth: [] }], +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // responses: { +// // "204": { description: "Booking deleted" }, +// // "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "403": { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "404": { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // }}, +// // }}, +// // }, +// // "/bookings/users/{userId}": { +// // get: { +// // summary: "List bookings by user ID", +// // tags: ["Bookings"], +// // operationId: "getBookingsByUserId", +// // parameters: [ +// // { +// // name: "userId", +// // in: "path", +// // required: true, +// // schema: { type: "string", format: "uuid" }, +// // }, +// // { $ref: "#/components/parameters/PageQuery" }, +// // { $ref: "#/components/parameters/PageSizeQuery" }, +// // ], +// // responses: { +// // "200": { +// // description: "List of bookings", +// // content: { +// // "application/json": { +// // schema: { +// // type: "array", +// // items: { $ref: "#/components/schemas/Booking" }, +// // }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // } +// // }, +// // "/bookings/attendant/{id}/status": { +// // patch: { +// // summary: "Update booking status (attendant)", +// // description: "Attendant or admin only. Sets status to pending, confirmed, or cancelled.", +// // tags: ["Bookings"], +// // operationId: "updateBookingStatus", +// // security: [{ cookieAuth: [] }], +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // requestBody: { +// // required: true, +// // content: { +// // "application/json": { +// // schema: { +// // type: "object", +// // required: ["status"], +// // properties: { +// // status: { type: "string", enum: ["pending", "confirmed", "cancelled"] }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // responses: { +// // "200": { +// // description: "Updated booking", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Booking" }, +// // }, +// // }, +// // }, +// // "400": { +// // description: "Invalid status", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "401": { +// // description: "Authentication required", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "403": { +// // description: "Access denied", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "404": { +// // description: "Booking not found", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // } +// // }, + +// // // ---------- Forum (posts & comments) ---------- +// // "/posts": { +// // get: { +// // summary: "List posts", +// // description: +// // "Optional query attendantOnly=true (requires attendant/admin). Paginated.", +// // tags: ["Forum"], +// // operationId: "getAllPosts", +// // parameters: [ +// // { $ref: "#/components/parameters/PageQuery" }, +// // { $ref: "#/components/parameters/PageSizeQuery" }, +// // { +// // name: "attendantOnly", +// // in: "query", +// // schema: { type: "string", enum: ["true", "false"] }, +// // }, +// // ], +// // responses: { +// // "200": { +// // description: "List of posts", +// // content: { +// // "application/json": { +// // schema: { +// // type: "array", +// // items: { $ref: "#/components/schemas/Post" }, +// // }, +// // }, +// // }, +// // }, +// // "403": { +// // description: "Access denied (attendant-only filter without role)", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // post: { +// // summary: "Create post", +// // description: "Authenticated. Sends WebSocket event on success.", +// // tags: ["Forum"], +// // operationId: "createPost", +// // security: [{ cookieAuth: [] }], +// // requestBody: { +// // required: true, +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/CreatePostRequest" }, +// // }, +// // }, +// // }, +// // responses: { +// // "201": { +// // description: "Post created", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Post" }, +// // }, +// // }, +// // }, +// // "400": { +// // description: "Validation error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/ValidationErrors" }, +// // }, +// // }, +// // }, +// // "401": { +// // description: "Authentication required", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "403": { +// // description: "Access denied (e.g. attendant-only post as visitor)", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // } +// // } +// // }, +// // "/posts/{id}": { +// // get: { +// // summary: "Get post by ID", +// // description: "Returns 403 for attendant-only posts if user is not attendant/admin.", +// // tags: ["Forum"], +// // operationId: "getPostById", +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // responses: { +// // "200": { +// // description: "Post", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Post" }, +// // }, +// // }, +// // }, +// // "403": { +// // description: "Access denied", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "404": { +// // description: "Post not found", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // put: { +// // summary: "Update post", +// // description: "Updates a post by ID. Author or admin only. Sends WebSocket event.", +// // tags: ["Forum"], +// // operationId: "updatePost", +// // security: [{ cookieAuth: [] }], +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // requestBody: { +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/UpdatePostRequest" }, +// // }, +// // }, +// // }, +// // responses: { +// // "200": { +// // description: "Updated post", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Post" }, +// // }, +// // }, +// // }, +// // "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationErrors" } } }}, +// // "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "403": { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "404": { description: "Post not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // }}, +// // }}, +// // delete: { +// // summary: "Delete post", +// // description: "Deletes a post by ID. Author or admin only. Sends WebSocket event.", +// // tags: ["Forum"], +// // operationId: "deletePost", +// // security: [{ cookieAuth: [] }], +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // responses: { +// // "204": { description: "Post deleted" }, +// // "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "403": { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "404": { description: "Post not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // }}, +// // }}, +// // }}}, +// // "/posts/{id}/comments": { +// // get: { +// // summary: "List comments for a post", +// // tags: ["Forum"], +// // operationId: "getCommentsByPostId", +// // parameters: [ +// // { $ref: "#/components/parameters/UuidPath" }, +// // { $ref: "#/components/parameters/PageQuery" }, +// // { $ref: "#/components/parameters/PageSizeQuery" }, +// // ], +// // responses: { +// // "200": { +// // description: "List of comments", +// // content: { +// // "application/json": { +// // schema: { +// // type: "array", +// // items: { $ref: "#/components/schemas/Comment" }, +// // }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // post: { +// // summary: "Add comment to post", +// // description: "Authenticated. Sends WebSocket event.", +// // tags: ["Forum"], +// // operationId: "createComment", +// // security: [{ cookieAuth: [] }], +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // requestBody: { +// // required: true, +// // content: { +// // "application/json": { +// // schema: { +// // type: "object", +// // required: ["content"], +// // properties: { +// // content: { type: "string" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // responses: { +// // "201": { +// // description: "Comment created", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Comment" }, +// // }, +// // }, +// // }, +// // "400": { +// // description: "Validation error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/ValidationErrors" }, +// // }, +// // }, +// // }, +// // "401": { +// // description: "Authentication required", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "403": { +// // description: "Access denied (e.g. attendant-only post)", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "404": { +// // description: "Post not found", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // "/posts/{postId}/comments/{commentId}": { +// // get: { +// // summary: "Get comment by ID", +// // tags: ["Forum"], +// // operationId: "getCommentById", +// // parameters: [ +// // { name: "postId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, +// // { name: "commentId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, +// // ], +// // responses: { +// // "200": { +// // description: "Comment", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Comment" }, +// // }, +// // }, +// // }, +// // "404": { description: "Comment not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "500": { description: "Internal server error", content: { "application/json": { schem : { $ref: "#/components/schemas/Error" } } }, +// // }}, +// // }}, +// // put: { +// // summary: "Update comment", +// // description: "Updates a comment by ID. Author or admin only.", +// // tags: ["Forum"], +// // operationId: "updateComment", +// // security: [{ cookieAuth: [] }], +// // parameters: [ +// // { name: "postId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, +// // { name: "commentId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, +// // ], +// // requestBody: { +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/UpdateCommentRequest" }, +// // }, +// // }, +// // }, +// // responses: { +// // "200": { +// // description: "Updated comment", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Comment" }, +// // }, +// // }, +// // }, +// // "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationErrors" } } }}, +// // "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "403": { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "404": { description: "Comment not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // }}, +// // }}, +// // delete: { +// // summary: "Delete comment", +// // description: "Deletes a comment by ID. Author or admin only.", +// // tags: ["Forum"], +// // operationId: "deleteComment", +// // security: [{ cookieAuth: [] }], +// // parameters: [ +// // { name: "postId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, +// // { name: "commentId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, +// // ], +// // responses: { +// // "204": { description: "Comment deleted" }, +// // "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }}, +// // "403": { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "404": { description: "Comment not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // }}, +// // }}, +// // }}, +// // "/posts/{id}/likes": { +// // get: { +// // summary: "List likes for a post", +// // description: "Returns paginated list of users who liked the post.", +// // tags: ["Post likes"], +// // operationId: "getPostLikes", +// // parameters: [ +// // { $ref: "#/components/parameters/UuidPath" }, +// // { $ref: "#/components/parameters/PageQuery" }, +// // { $ref: "#/components/parameters/PageSizeQuery" }, +// // ], +// // responses: { +// // "200": { +// // description: "List of post likes", +// // content: { +// // "application/json": { +// // schema: { +// // type: "array", +// // items: { $ref: "#/components/schemas/PostLike" }, +// // }, +// // }, +// // }, +// // }, +// // "404": { description: "Post not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// // }, +// // }}, +// // }}, +// // // ---------- Post likes ---------- +// // "/post_likes": { +// // post: { +// // summary: "Like a post", +// // description: "Records a like for the given post by the given user. Idempotent for same post/user.", +// // tags: ["Post likes"], +// // operationId: "likePost", +// // security: [{ cookieAuth: [] }], +// // requestBody: { +// // required: true, +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/LikePostRequest" }, +// // }, +// // }, +// // }, +// // responses: { +// // "200": { +// // description: "Like recorded", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/PostLike" }, +// // }, +// // }, +// // }, +// // "400": { +// // description: "Missing postId or userId", +// // content: { +// // "application/json": { +// // schema: { +// // type: "object", +// // properties: { +// // error: { type: "boolean" }, +// // message: { type: "string" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // delete: { +// // summary: "Unlike a post", +// // description: "Removes a like for the given post and user. Requires postId and userId in body.", +// // tags: ["Post likes"], +// // operationId: "unlikePost", +// // requestBody: { +// // required: true, +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/LikePostRequest" }, +// // }, +// // }, +// // }, +// // responses: { +// // "200": { +// // description: "Unliked", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Message" }, +// // }, +// // }, +// // }, +// // "400": { +// // description: "Missing postId or userId", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, + +// // // ---------- Contact ---------- +// // "/contact_messages": { +// // get: { +// // summary: "List contact messages", +// // tags: ["Contact"], +// // operationId: "getAllContactMessages", +// // parameters: [ +// // { $ref: "#/components/parameters/PageQuery" }, +// // { $ref: "#/components/parameters/PageSizeQuery" }, +// // ], +// // responses: { +// // "200": { +// // description: "List of contact messages", +// // content: { +// // "application/json": { +// // schema: { +// // type: "array", +// // items: { $ref: "#/components/schemas/ContactMessage" }, +// // }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // post: { +// // summary: "Submit contact form", +// // description: "Public contact form submission. Contact messages are immutable (no PUT); admins can delete via Admin API.", +// // tags: ["Contact"], +// // operationId: "createContactMessage", +// // requestBody: { +// // required: true, +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/CreateContactMessageRequest" }, +// // }, +// // }, +// // }, +// // responses: { +// // "201": { +// // description: "Message submitted", +// // content: { +// // "application/json": { +// // schema: { +// // type: "object", +// // properties: { +// // success: { type: "boolean", example: true }, +// // id: { type: "string", format: "uuid" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // "400": { +// // description: "Validation error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/ValidationErrors" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // "/contact_messages/unread_count": { +// // get: { +// // summary: "Get unread contact messages count", +// // tags: ["Contact"], +// // operationId: "getUnreadContactMessagesCount", +// // responses: { +// // "200": { +// // description: "Unread count", +// // content: { +// // "application/json": { +// // schema: { +// // type: "object", +// // required: ["count"], +// // properties: { +// // count: { type: "integer" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // "/contact_messages/{id}/read": { +// // patch: { +// // summary: "Mark contact message as read", +// // tags: ["Contact"], +// // operationId: "markContactMessageAsRead", +// // parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// // responses: { +// // "200": { +// // description: "Marked as read", +// // content: { +// // "application/json": { +// // schema: { +// // type: "object", +// // properties: { +// // success: { type: "boolean", example: true }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // "404": { +// // description: "Not found", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, + +// // // ---------- Admin ---------- +// // "/admin/contact-messages": { +// // get: { +// // summary: "List contact messages (admin)", +// // description: "Admin-only. Same as GET /contact_messages.", +// // tags: ["Admin"], +// // operationId: "adminGetAllContactMessages", +// // security: [{ cookieAuth: [] }], +// // parameters: [ +// // { $ref: "#/components/parameters/PageQuery" }, +// // { $ref: "#/components/parameters/PageSizeQuery" }, +// // ], +// // responses: { +// // "200": { +// // description: "List of contact messages", +// // content: { +// // "application/json": { +// // schema: { +// // type: "array", +// // items: { $ref: "#/components/schemas/ContactMessage" }, +// // }, +// // }, +// // }, +// // }, +// // "401": { +// // description: "Authentication required", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "403": { +// // description: "Admin access required", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // "500": { +// // description: "Internal server error", +// // content: { +// // "application/json": { +// // schema: { $ref: "#/components/schemas/Error" }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, +// // }, +// "/galleries": { +// get: { +// summary: "List gallery items", +// description: "Returns paginated gallery items. Optional query filter by category.", +// tags: ["Gallery"], +// operationId: "getAllGalleryItems", +// parameters: [ +// { $ref: "#/components/parameters/PageQuery" }, +// { $ref: "#/components/parameters/PageSizeQuery" }, +// { +// name: "category", +// in: "query", +// schema: { type: "string" }, +// }, +// ], +// responses: { +// "200": { +// description: "List of gallery items", +// content: { +// "application/json": { +// schema: { +// type: "array", +// items: { $ref: "#/components/schemas/GalleryItem" }, +// }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// post: { +// summary: "Create gallery item", +// description: "Requires multipart/form-data with image file and fields. Protected by session auth in production.", +// tags: ["Gallery"], +// operationId: "createGalleryItem", +// security: [{ cookieAuth: [] }], +// requestBody: { +// required: true, +// content: { +// "multipart/form-data": { +// schema: { +// type: "object", +// required: ["title", "description", "category"], +// properties: { +// title: { type: "string" }, +// description: { type: "string" }, +// imageUrl: { type: "string", description: "Optional if file uploaded" }, +// altText: { type: "string" }, +// category: { type: "string" }, +// }, +// }, +// }, +// }, +// }, +// responses: { +// "201": { +// description: "Gallery item created", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/GalleryItem" }, +// }, +// }, +// }, +// "400": { +// description: "Image file required or validation error", +// content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// }, +// "401": { +// description: "Authentication required", +// content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// }, +// "500": { +// description: "Internal server error", +// content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// }, +// }, +// }, +// }, + +// "/galleries/categories/{category}": { +// get: { +// summary: "List gallery items by category", +// tags: ["Gallery"], +// operationId: "getGalleryItemsByCategory", +// parameters: [ +// { name: "category", in: "path", required: true, schema: { type: "string" } }, +// { $ref: "#/components/parameters/PageQuery" }, +// { $ref: "#/components/parameters/PageSizeQuery" }, +// ], +// responses: { +// "200": { +// description: "List of gallery items", +// content: { +// "application/json": { +// schema: { +// type: "array", +// items: { $ref: "#/components/schemas/GalleryItem" }, +// }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// }, +// }, +// }, +// }, + +// "/galleries/{id}": { +// get: { +// summary: "Get gallery item by ID", +// tags: ["Gallery"], +// operationId: "getGalleryItemById", +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// responses: { +// "200": { +// description: "Gallery item", +// content: { "application/json": { schema: { $ref: "#/components/schemas/GalleryItem" } } }, +// }, +// "404": { +// description: "Not found", +// content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// }, +// "500": { +// description: "Internal server error", +// content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, +// }, +// }, +// }, +// post: { +// summary: "Create gallery item by ID", +// description: "Creates a new gallery item with a specific ID. Protected by session auth in production.", +// tags: ["Gallery"], +// operationId: "createGalleryItemById", +// security: [{ cookieAuth: [] }], +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// requestBody: { +// required: true, +// content: { +// "application/json": { schema: { $ref: "#/components/schemas/CreateGalleryItemRequest" } }, +// "multipart/form-data": { +// schema: { +// type: "object", +// required: ["title", "description", "category"], +// properties: { +// title: { type: "string" }, +// description: { type: "string" }, +// imageUrl: { type: "string" }, +// altText: { type: "string" }, +// category: { type: "string" }, +// }, +// }, +// }, +// }, +// }, +// responses: { +// "201": { description: "Gallery item created", content: { "application/json": { schema: { $ref: "#/components/schemas/GalleryItem" } } } }, +// "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationErrors" } } } }, +// "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, +// "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, +// }, +// }, +// put: { +// summary: "Update gallery item", +// description: "Updates an existing gallery item by ID. Protected by session auth in production.", +// tags: ["Gallery"], +// operationId: "updateGalleryItem", +// security: [{ cookieAuth: [] }], +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// requestBody: { +// content: { +// "application/json": { schema: { $ref: "#/components/schemas/UpdateGalleryItemRequest" } }, +// "multipart/form-data": { +// schema: { +// type: "object", +// properties: { +// title: { type: "string" }, +// description: { type: "string" }, +// imageUrl: { type: "string" }, +// altText: { type: "string" }, +// category: { type: "string" }, +// }, +// }, +// }, +// }, +// }, +// responses: { +// "200": { description: "Updated gallery item", content: { "application/json": { schema: { $ref: "#/components/schemas/GalleryItem" } } } }, +// "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationErrors" } } } }, +// "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, +// "404": { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, +// "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, +// }, +// }, +// delete: { +// summary: "Delete gallery item", +// description: "Deletes a gallery item by ID. Protected by session auth in production.", +// tags: ["Gallery"], +// operationId: "deleteGalleryItem", +// security: [{ cookieAuth: [] }], +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// responses: { +// "204": { description: "Gallery item deleted" }, +// "401": { description: "Authentication required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, +// "404": { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, +// "500": { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, +// }, +// }, +// }, + + +// "/admin/contact-messages/{id}": { +// get: { +// summary: "Get contact message by ID (admin)", +// description: "Admin-only. Returns a single contact message by UUID.", +// tags: ["Admin"], +// operationId: "adminGetContactMessageById", +// security: [{ cookieAuth: [] }], +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// responses: { +// "200": { +// description: "Contact message", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/ContactMessage" }, +// }, +// }, +// }, +// "401": { +// description: "Authentication required", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "403": { +// description: "Admin access required", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "404": { +// description: "Not found", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// delete: { +// summary: "Delete contact message (admin)", +// description: "Admin-only. Permanently deletes a contact message.", +// tags: ["Admin"], +// operationId: "adminDeleteContactMessage", +// security: [{ cookieAuth: [] }], +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// responses: { +// "204": { +// description: "Contact message deleted", +// }, +// "401": { +// description: "Authentication required", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "403": { +// description: "Admin access required", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "404": { +// description: "Not found", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// }, +// "/admin/contact-messages/{id}/read": { +// patch: { +// summary: "Mark contact message as read (admin)", +// tags: ["Admin"], +// operationId: "adminMarkContactMessageAsRead", +// security: [{ cookieAuth: [] }], +// parameters: [{ $ref: "#/components/parameters/UuidPath" }], +// responses: { +// "200": { +// description: "Marked as read", +// content: { +// "application/json": { +// schema: { +// type: "object", +// properties: { +// success: { type: "boolean", example: true }, +// }, +// }, +// }, +// }, +// }, +// "401": { +// description: "Authentication required", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "403": { +// description: "Admin access required", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "404": { +// description: "Not found", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// "500": { +// description: "Internal server error", +// content: { +// "application/json": { +// schema: { $ref: "#/components/schemas/Error" }, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// components: { +// securitySchemes: { +// cookieAuth: { +// type: "apiKey", +// in: "cookie", +// name: "connect.sid", +// description: "Session cookie set by the server after login. Send with credentials.", +// }, +// }, +// parameters: { +// PageQuery: { +// name: "page", +// in: "query", +// description: "Page number (1-based)", +// schema: { type: "integer", minimum: 1, default: 1 }, +// }, +// PageSizeQuery: { +// name: "pageSize", +// in: "query", +// description: "Items per page", +// schema: { type: "integer", minimum: 1, maximum: 100, default: 20 }, +// }, +// UuidPath: { +// name: "id", +// in: "path", +// required: true, +// description: "UUID of the resource", +// schema: { type: "string", format: "uuid" }, +// }, +// IdPath: { +// name: "id", +// in: "path", +// required: true, +// schema: { type: "string" }, +// }, +// }, +// schemas: { +// Error: { +// type: "object", +// required: ["success", "error", "timestamp", "path"], +// properties: { +// success: { type: "boolean", example: false }, +// error: { +// type: "object", +// required: ["message"], +// properties: { +// message: { type: "string" }, +// code: { type: "string" }, +// details: {}, +// stack: { type: "string", description: "Only in non-production" }, +// }, +// }, +// requestId: { type: "string" }, +// timestamp: { type: "string", format: "date-time" }, +// path: { type: "string" }, +// }, +// }, +// Message: { +// type: "object", +// properties: { +// message: { type: "string" }, +// }, +// }, +// ValidationErrors: { +// type: "object", +// properties: { +// errors: { +// type: "array", +// items: { +// type: "object", +// properties: { +// path: { type: "array", items: { type: "string" } }, +// message: { type: "string" }, +// }, +// }, +// }, +// }, +// }, +// User: { +// type: "object", +// properties: { +// id: { type: "string", format: "uuid" }, +// username: { type: "string" }, +// email: { type: "string", format: "email" }, +// fullName: { type: "string" }, +// userType: { type: "string", enum: ["visitor", "attendant", "admin"] }, +// profileImage: { type: "string", nullable: true }, +// bio: { type: "string", nullable: true }, +// createdAt: { type: "string", format: "date-time" }, +// updatedAt: { type: "string", format: "date-time" }, +// deletedAt: { type: "string", format: "date-time", nullable: true }, +// }, +// }, +// UserPublic: { +// type: "object", +// description: "User without password (e.g. login/register response)", +// properties: { +// id: { type: "string", format: "uuid" }, +// username: { type: "string" }, +// email: { type: "string", format: "email" }, +// fullName: { type: "string" }, +// userType: { type: "string", enum: ["visitor", "attendant", "admin"] }, +// profileImage: { type: "string", nullable: true }, +// bio: { type: "string", nullable: true }, +// createdAt: { type: "string", format: "date-time" }, +// updatedAt: { type: "string", format: "date-time" }, +// }, +// }, +// RegisterRequest: { +// type: "object", +// required: ["username", "password", "email", "fullName"], +// properties: { +// username: { type: "string", minLength: 3 }, +// password: { type: "string", minLength: 6 }, +// email: { type: "string", format: "email" }, +// fullName: { type: "string", minLength: 2 }, +// profileImage: { type: "string" }, +// bio: { type: "string" }, +// }, +// }, +// LoginRequest: { +// type: "object", +// required: ["username", "password"], +// properties: { +// username: { type: "string", description: "Username or email" }, +// password: { type: "string" }, +// }, +// }, +// CreateUserRequest: { +// type: "object", +// required: ["username", "password", "email", "fullName"], +// properties: { +// username: { type: "string", minLength: 3 }, +// password: { type: "string", minLength: 6 }, +// email: { type: "string", format: "email" }, +// fullName: { type: "string", minLength: 2 }, +// userType: { type: "string", enum: ["visitor", "attendant", "admin"] }, +// profileImage: { type: "string" }, +// bio: { type: "string" }, +// }, +// }, +// UpdateUserRequest: { +// type: "object", +// properties: { +// username: { type: "string", minLength: 3 }, +// email: { type: "string", format: "email" }, +// fullName: { type: "string", minLength: 2 }, +// userType: { type: "string", enum: ["visitor", "attendant", "admin"] }, +// profileImage: { type: "string" }, +// bio: { type: "string" }, +// }, +// }, +// HistoryContent: { +// type: "object", +// properties: { +// id: { type: "string", format: "uuid" }, +// title: { type: "string" }, +// slug: { type: "string" }, +// content: { type: "string" }, +// metaDescription: { type: "string" }, +// keywords: { type: "string" }, +// imageUrl: { type: "string", nullable: true }, +// authorId: { type: "string", format: "uuid", nullable: true }, +// createdAt: { type: "string", format: "date-time" }, +// updatedAt: { type: "string", format: "date-time" }, +// deletedAt: { type: "string", format: "date-time", nullable: true }, +// }, +// }, +// CreateHistoryContentRequest: { +// type: "object", +// required: ["title", "slug", "content", "metaDescription", "keywords"], +// properties: { +// title: { type: "string" }, +// slug: { type: "string" }, +// content: { type: "string" }, +// metaDescription: { type: "string" }, +// keywords: { type: "string" }, +// imageUrl: { type: "string" }, +// authorId: { type: "string", format: "uuid" }, +// }, +// }, +// UpdateHistoryContentRequest: { +// type: "object", +// properties: { +// title: { type: "string" }, +// slug: { type: "string" }, +// content: { type: "string" }, +// metaDescription: { type: "string" }, +// keywords: { type: "string" }, +// imageUrl: { type: "string" }, +// authorId: { type: "string", format: "uuid" }, +// }, +// }, +// GalleryItem: { +// type: "object", +// properties: { +// id: { type: "string", format: "uuid" }, +// title: { type: "string" }, +// description: { type: "string" }, +// imageUrl: { type: "string" }, +// altText: { type: "string", nullable: true }, +// category: { type: "string" }, +// createdAt: { type: "string", format: "date-time" }, +// updatedAt: { type: "string", format: "date-time" }, +// }, +// }, +// UpdateGalleryItemRequest: { +// type: "object", +// properties: { +// title: { type: "string" }, +// description: { type: "string" }, +// imageUrl: { type: "string" }, +// altText: { type: "string" }, +// category: { type: "string" }, +// }, +// }, +// Booking: { +// type: "object", +// properties: { +// id: { type: "string", format: "uuid" }, +// userId: { type: "string", format: "uuid", nullable: true }, +// fullName: { type: "string", nullable: true }, +// email: { type: "string", nullable: true }, +// phone: { type: "string", nullable: true }, +// visitDate: { type: "string", format: "date-time" }, +// groupSize: { type: "string" }, +// tourType: { type: "string" }, +// specialRequests: { type: "string", nullable: true }, +// status: { type: "string", enum: ["pending", "confirmed", "cancelled"] }, +// createdAt: { type: "string", format: "date-time" }, +// updatedAt: { type: "string", format: "date-time" }, +// }, +// }, +// CreateBookingRequest: { +// type: "object", +// required: ["fullName", "email", "phone", "visitDate", "groupSize", "tourType"], +// properties: { +// fullName: { type: "string" }, +// email: { type: "string", format: "email" }, +// phone: { type: "string", minLength: 6 }, +// visitDate: { type: "string", format: "date-time" }, +// groupSize: { type: "string" }, +// tourType: { type: "string" }, +// specialRequests: { type: "string" }, +// }, +// }, +// UpdateBookingRequest: { +// type: "object", +// properties: { +// fullName: { type: "string" }, +// email: { type: "string", format: "email" }, +// phone: { type: "string", minLength: 6 }, +// visitDate: { type: "string", format: "date-time" }, +// groupSize: { type: "string" }, +// tourType: { type: "string" }, +// specialRequests: { type: "string" }, +// status: { type: "string", enum: ["pending", "confirmed", "cancelled"] }, +// }, +// }, +// Post: { +// type: "object", +// properties: { +// id: { type: "string", format: "uuid" }, +// authorId: { type: "string", format: "uuid" }, +// title: { type: "string" }, +// slug: { type: "string" }, +// content: { type: "string" }, +// isAttendantOnly: { type: "boolean" }, +// createdAt: { type: "string", format: "date-time" }, +// updatedAt: { type: "string", format: "date-time" }, +// deletedAt: { type: "string", format: "date-time", nullable: true }, +// }, +// }, +// CreatePostRequest: { +// type: "object", +// required: ["title", "slug", "content"], +// properties: { +// title: { type: "string" }, +// slug: { type: "string" }, +// content: { type: "string" }, +// isAttendantOnly: { type: "boolean", default: false }, +// }, +// }, +// Comment: { +// type: "object", +// properties: { +// id: { type: "string", format: "uuid" }, +// postId: { type: "string", format: "uuid" }, +// authorId: { type: "string", format: "uuid" }, +// content: { type: "string" }, +// createdAt: { type: "string", format: "date-time" }, +// updatedAt: { type: "string", format: "date-time" }, +// }, +// }, +// UpdateCommentRequest: { +// type: "object", +// properties: { +// content: { type: "string", minLength: 1 }, +// }, +// }, +// PostLike: { +// type: "object", +// description: "A like on a forum post", +// properties: { +// id: { type: "string", format: "uuid" }, +// postId: { type: "string", format: "uuid" }, +// userId: { type: "string", format: "uuid" }, +// createdAt: { type: "string", format: "date-time" }, +// }, +// }, +// LikePostRequest: { +// type: "object", +// required: ["postId", "userId"], +// description: "Body for like and unlike post operations", +// properties: { +// postId: { type: "string", format: "uuid" }, +// userId: { type: "string", format: "uuid" }, +// }, +// }, +// ContactMessage: { +// type: "object", +// properties: { +// id: { type: "string", format: "uuid" }, +// fullName: { type: "string" }, +// email: { type: "string", format: "email" }, +// subject: { type: "string" }, +// message: { type: "string" }, +// isRead: { type: "boolean" }, +// createdAt: { type: "string", format: "date-time" }, +// }, +// }, +// CreateContactMessageRequest: { +// type: "object", +// required: ["fullName", "email", "subject", "message"], +// properties: { +// fullName: { type: "string" }, +// email: { type: "string", format: "email" }, +// subject: { type: "string" }, +// message: { type: "string", minLength: 10 }, +// }, +// }, +// UpdateContactMessageRequest: { +// type: "object", +// properties: { +// fullName: { type: "string" }, +// email: { type: "string", format: "email" }, +// subject: { type: "string" }, +// message: { type: "string", minLength: 10 }, +// isRead: { type: "boolean" }, +// }, +// }, +// }, +// }, +// }; +// export default spec; + +/** + * OpenAPI 3.0.3 spec for Museum Management REST API. + * Served at GET /api-docs (Swagger UI) and GET /api-docs.json (raw JSON). + * Base path: /api/v1 + */ + +const API_VERSION = process.env.npm_package_version ?? "1.0.0"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const spec: Record = { + openapi: "3.0.3", + info: { + title: "Museum Management REST API", + version: API_VERSION, + description: + "REST API for a digital museum platform: content management, session-based auth, bookings, forum, gallery, and real-time features.\n\n**Security:** Authenticated endpoints use session-based auth via `connect.sid` cookie. Call `/login` or `/register` with credentials to obtain a session. Use **Try it out** in Swagger UI with credentials enabled to test protected endpoints.\n\n**CRUD by collection:** History and Gallery support full CRUD (GET list, GET by id, POST, PUT, DELETE). Post likes use POST (like) and DELETE (unlike) only—no PUT, since a like is binary. Contact form messages are immutable from the public API (POST only); GET list, GET unread count, and PATCH read are available. Admin-only operations (see Admin tag) include GET/DELETE contact messages and marking as read.", + contact: { + name: "API support", + }, + license: { + name: "MIT", + url: "https://opensource.org/licenses/MIT", + }, + }, + servers: [ + { url: "/api/v1", description: "API v1 base path" }, + ], + tags: [ + { name: "Health", description: "Liveness and readiness probes" }, + { name: "Auth", description: "Registration, login, logout, current user" }, + { name: "Users", description: "User CRUD and profile" }, + { name: "History", description: "History content CRUD" }, + { name: "Gallery", description: "Gallery items" }, + { name: "Bookings", description: "Visit bookings" }, + { name: "Forum", description: "Posts and comments" }, + { name: "Post likes", description: "Like/unlike posts" }, + { name: "Contact", description: "Contact form and messages" }, + { name: "Admin", description: "Admin-only operations: contact messages list, get by ID, mark read, delete" }, + ], + paths: { + // ---------- Health ---------- + "/health": { + get: { + summary: "Health check (readiness)", + description: + "Returns service and dependency status. Use for load balancer readiness. Returns 503 if DB is down.", + tags: ["Health"], + operationId: "getHealth", + responses: { + "200": { + description: "Healthy", + content: { + "application/json": { + schema: { + type: "object", + required: ["success", "data"], + properties: { + success: { type: "boolean", example: true }, + data: { + type: "object", + properties: { + status: { type: "string", enum: ["healthy", "unhealthy"] }, + timestamp: { type: "string", format: "date-time" }, + version: { type: "string" }, + checks: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + "503": { + description: "Unhealthy (e.g. database down)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/live": { + get: { + summary: "Liveness probe", + description: + "Returns 200 if the process is running. Does not check dependencies. Use for Kubernetes liveness.", + tags: ["Health"], + operationId: "getLive", + responses: { + "200": { + description: "Process is alive", + content: { + "application/json": { + schema: { + type: "object", + required: ["success", "data"], + properties: { + success: { type: "boolean", example: true }, + data: { + type: "object", + properties: { + status: { type: "string", example: "alive" }, + timestamp: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/ready": { + get: { + summary: "Readiness probe", + description: "Same as /health. Returns 200 when service and DB are ready to accept traffic.", + tags: ["Health"], + operationId: "getReady", + responses: { + "200": { description: "Ready" }, + "503": { + description: "Not ready", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + + // ---------- Auth ---------- + "/register": { + post: { + summary: "Register and log in", + description: "Creates a new user and logs them in. Returns user (no password).", + tags: ["Auth"], + operationId: "register", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/RegisterRequest" }, + }, + }, + }, + responses: { + "201": { + description: "User created and logged in", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UserPublic" }, + }, + }, + }, + "400": { + description: "Username or email already exists", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/login": { + post: { + summary: "Log in", + description: "Authenticates with username/email and password. Sets session cookie.", + tags: ["Auth"], + operationId: "login", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/LoginRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Logged in", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UserPublic" }, + }, + }, + }, + "401": { + description: "Invalid credentials", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/logout": { + post: { + summary: "Log out", + description: "Destroys the current session.", + tags: ["Auth"], + operationId: "logout", + security: [{ cookieAuth: [] }], + responses: { + "200": { description: "Logged out" }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/current_user": { + get: { + summary: "Get current user", + description: "Returns the authenticated user (no password).", + tags: ["Auth"], + operationId: "getCurrentUser", + security: [{ cookieAuth: [] }], + responses: { + "200": { + description: "Current user", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UserPublic" }, + }, + }, + }, + "401": { + description: "Not authenticated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + + // ---------- Users ---------- + "/users": { + get: { + summary: "List users", + description: "Returns paginated users. Optional filter by role.", + tags: ["Users"], + operationId: "getAllUsers", + parameters: [ + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + { + name: "role", + in: "query", + description: "Filter by user role", + schema: { type: "string", enum: ["visitor", "attendant", "admin"] }, + }, + ], + responses: { + "200": { + description: "List of users", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/User" }, + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + post: { + summary: "Create user", + description: "Creates a new user (admin/registration use).", + tags: ["Users"], + operationId: "createUser", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateUserRequest" }, + }, + }, + }, + responses: { + "201": { + description: "User created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/User" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/users/{id}": { + get: { + summary: "Get user by ID or username", + description: + "Path parameter may be user UUID or username (route order determines which handler runs).", + tags: ["Users"], + operationId: "getUserById", + parameters: [ + { + name: "id", + in: "path", + required: true, + description: "User UUID or username", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "User", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/User" }, + }, + }, + }, + "404": { + description: "User not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + put: { + summary: "Update user", + tags: ["Users"], + operationId: "updateUser", + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateUserRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Updated user", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/User" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "404": { + description: "User not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + delete: { + summary: "Delete user by ID", + tags: ["Users"], + operationId: "deleteUserById", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "204": { description: "User deleted" }, + "404": { + description: "User not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + + // ---------- History ---------- + "/histories": { + get: { + summary: "List history content", + description: "Returns paginated history content.", + tags: ["History"], + operationId: "getAllHistoryContent", + parameters: [ + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + ], + responses: { + "200": { + description: "List of history content", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/HistoryContent" }, + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + post: { + summary: "Create history content", + description: "Creates new history content. Protected by session auth in production.", + tags: ["History"], + operationId: "createHistoryContent", + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateHistoryContentRequest" }, + }, + }, + }, + responses: { + "201": { + description: "History content created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/HistoryContent" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/histories/{id}": { + get: { + summary: "Get history content by ID or slug", + description: + "Path parameter may be UUID or slug. Route order on server determines which resolver runs.", + tags: ["History"], + operationId: "getHistoryContentById", + parameters: [ + { + name: "id", + in: "path", + required: true, + description: "History content UUID or slug", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "History content", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/HistoryContent" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + put: { + summary: "Update history content", + description: "Updates existing history content by UUID. Protected by session auth in production.", + tags: ["History"], + operationId: "updateHistoryContent", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateHistoryContentRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Updated history content", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/HistoryContent" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + delete: { + summary: "Delete history content", + description: "Soft-deletes history content by UUID. Protected by session auth in production.", + tags: ["History"], + operationId: "deleteHistoryContent", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "200": { + description: "Deleted", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + + // ---------- Gallery ---------- + "/galleries": { + get: { + summary: "List gallery items", + description: "Returns paginated gallery items. Optional query filter by category.", + tags: ["Gallery"], + operationId: "getAllGalleryItems", + parameters: [ + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + { + name: "category", + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "List of gallery items", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/GalleryItem" }, + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + post: { + summary: "Create gallery item", + description: "Requires multipart/form-data with image file and fields. Protected by session auth in production.", + tags: ["Gallery"], + operationId: "createGalleryItem", + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + required: ["title", "description", "category"], + properties: { + title: { type: "string" }, + description: { type: "string" }, + imageUrl: { type: "string", description: "Optional if file uploaded" }, + altText: { type: "string" }, + category: { type: "string" }, + }, + }, + }, + }, + }, + responses: { + "201": { + description: "Gallery item created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/GalleryItem" }, + }, + }, + }, + "400": { + description: "Image file required or validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/galleries/categories/{category}": { + get: { + summary: "List gallery items by category", + tags: ["Gallery"], + operationId: "getGalleryItemsByCategory", + parameters: [ + { + name: "category", + in: "path", + required: true, + schema: { type: "string" }, + }, + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + ], + responses: { + "200": { + description: "List of gallery items", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/GalleryItem" }, + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/galleries/{id}": { + get: { + summary: "Get gallery item by ID", + tags: ["Gallery"], + operationId: "getGalleryItemById", + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "200": { + description: "Gallery item", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/GalleryItem" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + put: { + summary: "Update gallery item", + description: "Updates an existing gallery item by ID. Protected by session auth in production.", + tags: ["Gallery"], + operationId: "updateGalleryItem", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateGalleryItemRequest" }, + }, + "multipart/form-data": { + schema: { + type: "object", + properties: { + title: { type: "string" }, + description: { type: "string" }, + imageUrl: { type: "string" }, + altText: { type: "string" }, + category: { type: "string" }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Updated gallery item", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/GalleryItem" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + delete: { + summary: "Delete gallery item", + description: "Deletes a gallery item by ID. Protected by session auth in production.", + tags: ["Gallery"], + operationId: "deleteGalleryItem", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "204": { description: "Gallery item deleted" }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + + // ---------- Bookings ---------- + "/bookings": { + get: { + summary: "List bookings", + description: + "Authenticated. Visitors see only their bookings; attendants/admins see all. Optional filter by status (query).", + tags: ["Bookings"], + operationId: "getAllBookings", + security: [{ cookieAuth: [] }], + parameters: [ + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + { + name: "status", + in: "query", + schema: { type: "string", enum: ["pending", "confirmed", "cancelled"] }, + }, + ], + responses: { + "200": { + description: "List of bookings", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Booking" }, + }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + post: { + summary: "Create booking", + description: "Creates a booking. If authenticated, userId is set from session. Sends WebSocket event.", + tags: ["Bookings"], + operationId: "createBooking", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateBookingRequest" }, + }, + }, + }, + responses: { + "201": { + description: "Booking created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Booking" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/bookings/{id}": { + get: { + summary: "Get booking by ID", + tags: ["Bookings"], + operationId: "getBookingById", + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "200": { + description: "Booking", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Booking" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + put: { + summary: "Update booking", + description: "Full update of a booking by ID. Authenticated; visitors may only update their own.", + tags: ["Bookings"], + operationId: "updateBooking", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateBookingRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Updated booking", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Booking" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Forbidden", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + delete: { + summary: "Delete booking", + description: "Deletes a booking by ID. Authenticated; visitors may only delete their own.", + tags: ["Bookings"], + operationId: "deleteBooking", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "204": { description: "Booking deleted" }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Forbidden", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/bookings/users/{userId}": { + get: { + summary: "List bookings by user ID", + tags: ["Bookings"], + operationId: "getBookingsByUserId", + parameters: [ + { + name: "userId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + ], + responses: { + "200": { + description: "List of bookings", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Booking" }, + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/bookings/attendant/{id}/status": { + patch: { + summary: "Update booking status (attendant)", + description: "Attendant or admin only. Sets status to pending, confirmed, or cancelled.", + tags: ["Bookings"], + operationId: "updateBookingStatus", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["pending", "confirmed", "cancelled"] }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Updated booking", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Booking" }, + }, + }, + }, + "400": { + description: "Invalid status", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Access denied", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Booking not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + + // ---------- Forum (posts & comments) ---------- + "/posts": { + get: { + summary: "List posts", + description: + "Optional query attendantOnly=true (requires attendant/admin). Paginated.", + tags: ["Forum"], + operationId: "getAllPosts", + parameters: [ + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + { + name: "attendantOnly", + in: "query", + schema: { type: "string", enum: ["true", "false"] }, + }, + ], + responses: { + "200": { + description: "List of posts", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Post" }, + }, + }, + }, + }, + "403": { + description: "Access denied (attendant-only filter without role)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + post: { + summary: "Create post", + description: "Authenticated. Sends WebSocket event on success.", + tags: ["Forum"], + operationId: "createPost", + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreatePostRequest" }, + }, + }, + }, + responses: { + "201": { + description: "Post created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Post" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "403": { + description: "Access denied (e.g. attendant-only post as visitor)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/posts/{id}": { + get: { + summary: "Get post by ID", + description: "Returns 403 for attendant-only posts if user is not attendant/admin.", + tags: ["Forum"], + operationId: "getPostById", + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "200": { + description: "Post", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Post" }, + }, + }, + }, + "403": { + description: "Access denied", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "404": { + description: "Post not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + put: { + summary: "Update post", + description: "Updates a post by ID. Author or admin only. Sends WebSocket event.", + tags: ["Forum"], + operationId: "updatePost", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdatePostRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Updated post", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Post" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Forbidden", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Post not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + delete: { + summary: "Delete post", + description: "Deletes a post by ID. Author or admin only. Sends WebSocket event.", + tags: ["Forum"], + operationId: "deletePost", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "204": { description: "Post deleted" }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Forbidden", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Post not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/posts/{id}/comments": { + get: { + summary: "List comments for a post", + tags: ["Forum"], + operationId: "getCommentsByPostId", + parameters: [ + { $ref: "#/components/parameters/UuidPath" }, + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + ], + responses: { + "200": { + description: "List of comments", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Comment" }, + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + post: { + summary: "Add comment to post", + description: "Authenticated. Sends WebSocket event.", + tags: ["Forum"], + operationId: "createComment", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["content"], + properties: { + content: { type: "string" }, + }, + }, + }, + }, + }, + responses: { + "201": { + description: "Comment created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Comment" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "403": { + description: "Access denied (e.g. attendant-only post)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "404": { + description: "Post not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/posts/{postId}/comments/{commentId}": { + get: { + summary: "Get comment by ID", + tags: ["Forum"], + operationId: "getCommentById", + parameters: [ + { name: "postId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, + { name: "commentId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, + ], + responses: { + "200": { + description: "Comment", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Comment" }, + }, + }, + }, + "404": { + description: "Comment not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + put: { + summary: "Update comment", + description: "Updates a comment by ID. Author or admin only.", + tags: ["Forum"], + operationId: "updateComment", + security: [{ cookieAuth: [] }], + parameters: [ + { name: "postId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, + { name: "commentId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, + ], + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateCommentRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Updated comment", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Comment" }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Forbidden", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Comment not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + delete: { + summary: "Delete comment", + description: "Deletes a comment by ID. Author or admin only.", + tags: ["Forum"], + operationId: "deleteComment", + security: [{ cookieAuth: [] }], + parameters: [ + { name: "postId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, + { name: "commentId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, + ], + responses: { + "204": { description: "Comment deleted" }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Forbidden", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Comment not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/posts/{id}/likes": { + get: { + summary: "List likes for a post", + description: "Returns paginated list of users who liked the post.", + tags: ["Post likes"], + operationId: "getPostLikes", + parameters: [ + { $ref: "#/components/parameters/UuidPath" }, + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + ], + responses: { + "200": { + description: "List of post likes", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/PostLike" }, + }, + }, + }, + }, + "404": { + description: "Post not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + + // ---------- Post likes ---------- + "/post_likes": { + post: { + summary: "Like a post", + description: "Records a like for the given post by the given user. Idempotent for same post/user.", + tags: ["Post likes"], + operationId: "likePost", + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/LikePostRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Like recorded", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/PostLike" }, + }, + }, + }, + "400": { + description: "Missing postId or userId", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { type: "boolean" }, + message: { type: "string" }, + }, + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + delete: { + summary: "Unlike a post", + description: "Removes a like for the given post and user. Requires postId and userId in body.", + tags: ["Post likes"], + operationId: "unlikePost", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/LikePostRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Unliked", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + "400": { + description: "Missing postId or userId", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + + // ---------- Contact ---------- + "/contact_messages": { + get: { + summary: "List contact messages", + tags: ["Contact"], + operationId: "getAllContactMessages", + parameters: [ + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + ], + responses: { + "200": { + description: "List of contact messages", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/ContactMessage" }, + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + post: { + summary: "Submit contact form", + description: "Public contact form submission. Contact messages are immutable (no PUT); admins can delete via Admin API.", + tags: ["Contact"], + operationId: "createContactMessage", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateContactMessageRequest" }, + }, + }, + }, + responses: { + "201": { + description: "Message submitted", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + id: { type: "string", format: "uuid" }, + }, + }, + }, + }, + }, + "400": { + description: "Validation error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ValidationErrors" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/contact_messages/unread_count": { + get: { + summary: "Get unread contact messages count", + tags: ["Contact"], + operationId: "getUnreadContactMessagesCount", + responses: { + "200": { + description: "Unread count", + content: { + "application/json": { + schema: { + type: "object", + required: ["count"], + properties: { + count: { type: "integer" }, + }, + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/contact_messages/{id}/read": { + patch: { + summary: "Mark contact message as read", + tags: ["Contact"], + operationId: "markContactMessageAsRead", + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "200": { + description: "Marked as read", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + }, + }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + + // ---------- Admin ---------- + "/admin/contact-messages": { + get: { + summary: "List contact messages (admin)", + description: "Admin-only. Same as GET /contact_messages.", + tags: ["Admin"], + operationId: "adminGetAllContactMessages", + security: [{ cookieAuth: [] }], + parameters: [ + { $ref: "#/components/parameters/PageQuery" }, + { $ref: "#/components/parameters/PageSizeQuery" }, + ], + responses: { + "200": { + description: "List of contact messages", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/ContactMessage" }, + }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Admin access required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/admin/contact-messages/{id}": { + get: { + summary: "Get contact message by ID (admin)", + description: "Admin-only. Returns a single contact message by UUID.", + tags: ["Admin"], + operationId: "adminGetContactMessageById", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "200": { + description: "Contact message", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ContactMessage" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Admin access required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + delete: { + summary: "Delete contact message (admin)", + description: "Admin-only. Permanently deletes a contact message.", + tags: ["Admin"], + operationId: "adminDeleteContactMessage", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "204": { + description: "Contact message deleted", + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Admin access required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/admin/contact-messages/{id}/read": { + patch: { + summary: "Mark contact message as read (admin)", + tags: ["Admin"], + operationId: "adminMarkContactMessageAsRead", + security: [{ cookieAuth: [] }], + parameters: [{ $ref: "#/components/parameters/UuidPath" }], + responses: { + "200": { + description: "Marked as read", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + }, + }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Admin access required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + }, + + components: { + securitySchemes: { + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "connect.sid", + description: "Session cookie set by the server after login. Send with credentials.", + }, + }, + parameters: { + PageQuery: { + name: "page", + in: "query", + description: "Page number (1-based)", + schema: { type: "integer", minimum: 1, default: 1 }, + }, + PageSizeQuery: { + name: "pageSize", + in: "query", + description: "Items per page", + schema: { type: "integer", minimum: 1, maximum: 100, default: 20 }, + }, + UuidPath: { + name: "id", + in: "path", + required: true, + description: "UUID of the resource", + schema: { type: "string", format: "uuid" }, + }, + IdPath: { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + }, + schemas: { + Error: { + type: "object", + required: ["success", "error", "timestamp", "path"], + properties: { + success: { type: "boolean", example: false }, + error: { + type: "object", + required: ["message"], + properties: { + message: { type: "string" }, + code: { type: "string" }, + details: {}, + stack: { type: "string", description: "Only in non-production" }, + }, + }, + requestId: { type: "string" }, + timestamp: { type: "string", format: "date-time" }, + path: { type: "string" }, + }, + }, + Message: { + type: "object", + properties: { + message: { type: "string" }, + }, + }, + ValidationErrors: { + type: "object", + properties: { + errors: { + type: "array", + items: { + type: "object", + properties: { + path: { type: "array", items: { type: "string" } }, + message: { type: "string" }, + }, + }, + }, + }, + }, + User: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + username: { type: "string" }, + email: { type: "string", format: "email" }, + fullName: { type: "string" }, + userType: { type: "string", enum: ["visitor", "attendant", "admin"] }, + profileImage: { type: "string", nullable: true }, + bio: { type: "string", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + deletedAt: { type: "string", format: "date-time", nullable: true }, + }, + }, + UserPublic: { + type: "object", + description: "User without password (e.g. login/register response)", + properties: { + id: { type: "string", format: "uuid" }, + username: { type: "string" }, + email: { type: "string", format: "email" }, + fullName: { type: "string" }, + userType: { type: "string", enum: ["visitor", "attendant", "admin"] }, + profileImage: { type: "string", nullable: true }, + bio: { type: "string", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + RegisterRequest: { + type: "object", + required: ["username", "password", "email", "fullName"], + properties: { + username: { type: "string", minLength: 3 }, + password: { type: "string", minLength: 6 }, + email: { type: "string", format: "email" }, + fullName: { type: "string", minLength: 2 }, + profileImage: { type: "string" }, + bio: { type: "string" }, + }, + }, + LoginRequest: { + type: "object", + required: ["username", "password"], + properties: { + username: { type: "string", description: "Username or email" }, + password: { type: "string" }, + }, + }, + CreateUserRequest: { + type: "object", + required: ["username", "password", "email", "fullName"], + properties: { + username: { type: "string", minLength: 3 }, + password: { type: "string", minLength: 6 }, + email: { type: "string", format: "email" }, + fullName: { type: "string", minLength: 2 }, + userType: { type: "string", enum: ["visitor", "attendant", "admin"] }, + profileImage: { type: "string" }, + bio: { type: "string" }, + }, + }, + UpdateUserRequest: { + type: "object", + properties: { + username: { type: "string", minLength: 3 }, + email: { type: "string", format: "email" }, + fullName: { type: "string", minLength: 2 }, + userType: { type: "string", enum: ["visitor", "attendant", "admin"] }, + profileImage: { type: "string" }, + bio: { type: "string" }, + }, + }, + HistoryContent: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + title: { type: "string" }, + slug: { type: "string" }, + content: { type: "string" }, + metaDescription: { type: "string" }, + keywords: { type: "string" }, + imageUrl: { type: "string", nullable: true }, + authorId: { type: "string", format: "uuid", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + deletedAt: { type: "string", format: "date-time", nullable: true }, + }, + }, + CreateHistoryContentRequest: { + type: "object", + required: ["title", "slug", "content", "metaDescription", "keywords"], + properties: { + title: { type: "string" }, + slug: { type: "string" }, + content: { type: "string" }, + metaDescription: { type: "string" }, + keywords: { type: "string" }, + imageUrl: { type: "string" }, + authorId: { type: "string", format: "uuid" }, + }, + }, + UpdateHistoryContentRequest: { + type: "object", + properties: { + title: { type: "string" }, + slug: { type: "string" }, + content: { type: "string" }, + metaDescription: { type: "string" }, + keywords: { type: "string" }, + imageUrl: { type: "string" }, + authorId: { type: "string", format: "uuid" }, + }, + }, + GalleryItem: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + title: { type: "string" }, + description: { type: "string" }, + imageUrl: { type: "string" }, + altText: { type: "string", nullable: true }, + category: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + CreateGalleryItemRequest: { + type: "object", + required: ["title", "description", "imageUrl", "category"], + properties: { + title: { type: "string" }, + description: { type: "string" }, + imageUrl: { type: "string" }, + altText: { type: "string" }, + category: { type: "string" }, + }, + }, + UpdateGalleryItemRequest: { + type: "object", + properties: { + title: { type: "string" }, + description: { type: "string" }, + imageUrl: { type: "string" }, + altText: { type: "string" }, + category: { type: "string" }, + }, + }, + Booking: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + userId: { type: "string", format: "uuid", nullable: true }, + fullName: { type: "string", nullable: true }, + email: { type: "string", nullable: true }, + phone: { type: "string", nullable: true }, + visitDate: { type: "string", format: "date-time" }, + groupSize: { type: "string" }, + tourType: { type: "string" }, + specialRequests: { type: "string", nullable: true }, + status: { type: "string", enum: ["pending", "confirmed", "cancelled"] }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + CreateBookingRequest: { + type: "object", + required: ["fullName", "email", "phone", "visitDate", "groupSize", "tourType"], + properties: { + fullName: { type: "string" }, + email: { type: "string", format: "email" }, + phone: { type: "string", minLength: 6 }, + visitDate: { type: "string", format: "date-time" }, + groupSize: { type: "string" }, + tourType: { type: "string" }, + specialRequests: { type: "string" }, + }, + }, + UpdateBookingRequest: { + type: "object", + properties: { + fullName: { type: "string" }, + email: { type: "string", format: "email" }, + phone: { type: "string", minLength: 6 }, + visitDate: { type: "string", format: "date-time" }, + groupSize: { type: "string" }, + tourType: { type: "string" }, + specialRequests: { type: "string" }, + status: { type: "string", enum: ["pending", "confirmed", "cancelled"] }, + }, + }, + Post: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + authorId: { type: "string", format: "uuid" }, + title: { type: "string" }, + slug: { type: "string" }, + content: { type: "string" }, + isAttendantOnly: { type: "boolean" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + deletedAt: { type: "string", format: "date-time", nullable: true }, + }, + }, + CreatePostRequest: { + type: "object", + required: ["title", "slug", "content"], + properties: { + title: { type: "string" }, + slug: { type: "string" }, + content: { type: "string" }, + isAttendantOnly: { type: "boolean", default: false }, + }, + }, + UpdatePostRequest: { + type: "object", + properties: { + title: { type: "string" }, + slug: { type: "string" }, + content: { type: "string" }, + isAttendantOnly: { type: "boolean" }, + }, + }, + Comment: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + postId: { type: "string", format: "uuid" }, + authorId: { type: "string", format: "uuid" }, + content: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + UpdateCommentRequest: { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + }, + PostLike: { + type: "object", + description: "A like on a forum post", + properties: { + id: { type: "string", format: "uuid" }, + postId: { type: "string", format: "uuid" }, + userId: { type: "string", format: "uuid" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + LikePostRequest: { + type: "object", + required: ["postId", "userId"], + description: "Body for like and unlike post operations", + properties: { + postId: { type: "string", format: "uuid" }, + userId: { type: "string", format: "uuid" }, + }, + }, + ContactMessage: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + fullName: { type: "string" }, + email: { type: "string", format: "email" }, + subject: { type: "string" }, + message: { type: "string" }, + isRead: { type: "boolean" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + CreateContactMessageRequest: { + type: "object", + required: ["fullName", "email", "subject", "message"], + properties: { + fullName: { type: "string" }, + email: { type: "string", format: "email" }, + subject: { type: "string" }, + message: { type: "string", minLength: 10 }, + }, + }, + UpdateContactMessageRequest: { + type: "object", + properties: { + fullName: { type: "string" }, + email: { type: "string", format: "email" }, + subject: { type: "string" }, + message: { type: "string", minLength: 10 }, + isRead: { type: "boolean" }, + }, + }, + }, + }, +}; + +export default spec; \ No newline at end of file diff --git a/config/security/helmet-config.ts b/config/security/helmet-config.ts new file mode 100644 index 0000000..5fe57b1 --- /dev/null +++ b/config/security/helmet-config.ts @@ -0,0 +1,85 @@ +import helmet from "helmet"; +import type { ExpressApp } from "../../types/express-app"; + +const helmetOptions = { + // Content Security Policy + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:", "https://images.unsplash.com"], + connectSrc: ["'self'"], + fontSrc: ["'self'", "https://fonts.gstatic.com"], + objectSrc: ["'none'"], + mediaSrc: ["'self'"], + frameSrc: ["'none'"], + }, + }, + // Cross-Origin Embedder Policy + crossOriginEmbedderPolicy: false, // Set to true if you don't need to embed external resources + // Cross-Origin Opener Policy + crossOriginOpenerPolicy: { policy: "same-origin" }, + // Cross-Origin Resource Policy + crossOriginResourcePolicy: { policy: "cross-origin" }, + // DNS Prefetch Control + dnsPrefetchControl: true, + // Frameguard - prevents clickjacking + frameguard: { action: "deny" }, + // Hide Powered-By header + hidePoweredBy: true, + // HSTS - HTTP Strict Transport Security + hsts: { + maxAge: 31536000, // 1 year + includeSubDomains: true, + preload: true, + }, + // IE No Open + ieNoOpen: true, + // No Sniff - prevents MIME type sniffing + noSniff: true, + // Origin Agent Cluster + originAgentCluster: true, + // Permissions Policy (formerly Feature Policy) + permissionsPolicy: { + features: { + accelerometer: ["'none'"], + ambientLightSensor: ["'none'"], + autoplay: ["'none'"], + battery: ["'none'"], + camera: ["'none'"], + crossOriginIsolated: ["'none'"], + displayCapture: ["'none'"], + documentDomain: ["'none'"], + encryptedMedia: ["'none'"], + executionWhileNotRendered: ["'none'"], + executionWhileOutOfViewport: ["'none'"], + fullscreen: ["'self'"], + geolocation: ["'none'"], + gyroscope: ["'none'"], + keyboardMap: ["'none'"], + magnetometer: ["'none'"], + microphone: ["'none'"], + midi: ["'none'"], + navigationOverride: ["'none'"], + payment: ["'none'"], + pictureInPicture: ["'none'"], + publickeyCredentials: ["'none'"], + screenWakeLock: ["'none'"], + syncXhr: ["'none'"], + usb: ["'none'"], + webShare: ["'none'"], + xrSpatialTracking: ["'none'"], + }, + }, + // Referrer Policy + referrerPolicy: { policy: "no-referrer" }, + // XSS Protection (legacy, but kept for older browsers) + xssFilter: true, +}; + +export const configureHelmet = (app: ExpressApp) => { + app.use(helmet(helmetOptions as Parameters[0])); +}; + +export default configureHelmet; diff --git a/docs/FEATURES_SUMMARY.md b/docs/FEATURES_SUMMARY.md new file mode 100644 index 0000000..f72c361 --- /dev/null +++ b/docs/FEATURES_SUMMARY.md @@ -0,0 +1,98 @@ +# Museum Management REST API — Features Summary + +Summary of infrastructure and cross-cutting features for the **Museum Management REST API** (`museum-management-rest-api`). + +--- + +## Completed features (merged to `main`) + +### 1. Environment variable validation (Zod) + +- **Location**: `config/env/env-validation.ts` +- **Purpose**: Validate all required env vars at startup with a Zod schema; app exits with clear errors if config is invalid. +- **Usage**: Import `env` from `./config/env/env-validation`; use `env.PORT`, `env.NODE_ENV`, etc. No raw `process.env` for validated keys. + +### 2. Structured logging (Winston) + +- **Location**: `config/logger/logger-config.ts` +- **Purpose**: Central Winston logger with levels, timestamps, and optional file/console transports. +- **Usage**: Import `logger` from `./config/logger/logger-config`; use `logger.info()`, `logger.error()`, etc. with structured metadata. + +### 3. Security headers (Helmet.js) + +- **Location**: `config/security/helmet-config.ts` +- **Purpose**: Apply secure HTTP headers (CSP, HSTS, X-Frame-Options, etc.) via Helmet. +- **Usage**: Applied in `app.ts` via `configureHelmet(app)`. + +### 4. Request ID / tracing middleware + +- **Location**: `middlewares/request-id.ts` +- **Purpose**: Assign a unique `requestId` per request (or use `X-Request-ID` if provided); set on `req.requestId` and `X-Request-ID` response header for tracing. +- **Usage**: Applied early in `app.ts`; error handler and request logging include `requestId` when present. + +### 5. Centralized configuration management + +- **Location**: `config/` (env, auth, cors, database, bucket-storage, logger, security) +- **Purpose**: Single place for server, auth, CORS, DB, and other config; consumed via validated `env` and dedicated config modules. +- **Usage**: Entry point uses `createApp()` from `app.ts`; routes and services import from `config/` and `env`. + +### 6. Standardized error responses + +- **Location**: `middlewares/errors/error-response.ts`, `middlewares/errors/error-handler.ts` +- **Purpose**: Single JSON error shape and status codes across the API; `AppError` subclasses and central error handler produce consistent responses. +- **Usage**: Throw `ValidationError`, `NotFoundError`, `DatabaseError`, `UnauthorizedError`, `ForbiddenError` from controllers; use `sendErrorResponse(res, ...)` for one-off errors. Global handler in `index.ts` catches and formats all errors with `requestId`, `timestamp`, `path`. + +--- + +## Application entry flow + +1. **`index.ts`** — Entry point: validates env (imports `config/env/env-validation`), calls `createApp()`, calls `registerRoutes("/api/v1", app)` to mount routes and get the HTTP server, registers `errorHandler` middleware, then starts the server with `server.listen(PORT)`. +2. **`app.ts`** — Builds the Express app only (no listen): request-id → body parsers → CORS → Helmet → request logging (with `requestId` in logs). No routes mounted here. +3. **`server/routes/index.ts`** — `registerRoutes(basePath, app)` creates the HTTP server, attaches WebSocket server at `/ws`, configures auth, and mounts all route modules under `basePath` (e.g. `/api/v1`). Returns the same HTTP server so `index.ts` can call `listen()` on it. +4. **Routes** — Mounted under `/api/v1`; unhandled errors are caught by `middlewares/errors/error-handler.ts`, which returns a standardized JSON error (with `requestId`, `timestamp`, `path`). + +--- + +## Additional features (implemented) + +### 7. OpenAPI / Swagger + +- **Location**: `config/openapi/spec.ts`, `server/routes/api-docs-route.ts` +- **Purpose**: Interactive API docs and machine-readable OpenAPI 3 spec. +- **Usage**: GET `/api-docs` for Swagger UI, GET `/api-docs.json` for the raw spec. Mounted in `registerRoutes` via `mountApiDocs(app)`. + +### 8. Improved health checks + +- **Location**: `server/controllers/health.controller.ts`, `server/routes/health-check-route.ts` +- **Purpose**: Liveness and readiness for orchestrators (e.g. Kubernetes). +- **Endpoints**: GET `/api/v1/health` (readiness, includes DB check), GET `/api/v1/live` (liveness, no DB), GET `/api/v1/ready` (alias for readiness). + +### 9. Global rate limiting + +- **Location**: `middlewares/global-rate-limit.ts` +- **Purpose**: App-wide rate limit per IP (e.g. 200 req/15 min). Health and `/api-docs` are skipped. +- **Usage**: Applied in `app.ts` via `globalRateLimiter`. Uses `express-rate-limit`; trust proxy in production. + +### 10. Docker support + +- **Location**: `Dockerfile`, `.dockerignore` +- **Purpose**: Multi-stage build for production image; run with `docker build -t museum-management-rest-api .` and pass env (e.g. `DATABASE_URL`, `SESSION_SECRET`) at runtime. + +### 11. CI/CD pipeline + +- **Location**: `.github/workflows/ci.yml` +- **Purpose**: On push/PR to main/master/develop: test, lint, typecheck, build; on push to main/master: build production artifact and Docker image (no push to registry by default; add secrets for deploy). + +--- + +## Branch reference (historical) + +These features were developed on branches and merged into `main`: + +- `feature/config-management` +- `feature/env-validation` +- `feature/structured-logging` +- `feature/request-tracing` +- `feature/security-headers` + +Further work should branch from `main`. diff --git a/drizzle.config.ts b/drizzle.config.ts index 679a057..fc0ebe7 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,10 @@ import { defineConfig, Config } from "drizzle-kit"; +import { env } from "./config/env/env-validation"; -if (!process.env.DATABASE_URL_PROD) { - throw new Error("DATABASE_URL, ensure the database is provisioned"); +if (!env.DATABASE_URL_PROD && !env.DATABASE_URL) { + throw new Error( + "Either DATABASE_URL or DATABASE_URL_PROD must be set. Ensure the database is provisioned" + ); } export default defineConfig({ @@ -9,7 +12,7 @@ export default defineConfig({ schema: "./config/database/schema", dialect: "postgresql", dbCredentials: { - url: process.env.DATABASE_URL_PROD, + url: env.DATABASE_URL_PROD || env.DATABASE_URL || "", }, verbose: true, strict: true, diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 4f50999..665c8eb 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -92,12 +92,8 @@ "name": "bookings_user_id_users_id_fk", "tableFrom": "bookings", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -158,12 +154,8 @@ "name": "comments_post_id_posts_id_fk", "tableFrom": "comments", "tableTo": "posts", - "columnsFrom": [ - "post_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["post_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -171,12 +163,8 @@ "name": "comments_author_id_users_id_fk", "tableFrom": "comments", "tableTo": "users", - "columnsFrom": [ - "author_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["author_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -346,12 +334,8 @@ "name": "group_members_group_id_groups_id_fk", "tableFrom": "group_members", "tableTo": "groups", - "columnsFrom": [ - "group_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["group_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -359,12 +343,8 @@ "name": "group_members_user_id_users_id_fk", "tableFrom": "group_members", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -374,10 +354,7 @@ "group_members_group_id_user_id_unique": { "name": "group_members_group_id_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "group_id", - "user_id" - ] + "columns": ["group_id", "user_id"] } }, "policies": {}, @@ -447,12 +424,8 @@ "name": "groups_created_by_id_users_id_fk", "tableFrom": "groups", "tableTo": "users", - "columnsFrom": [ - "created_by_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -462,9 +435,7 @@ "groups_slug_unique": { "name": "groups_slug_unique", "nullsNotDistinct": false, - "columns": [ - "slug" - ] + "columns": ["slug"] } }, "policies": {}, @@ -551,12 +522,8 @@ "name": "history_content_author_id_users_id_fk", "tableFrom": "history_content", "tableTo": "users", - "columnsFrom": [ - "author_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["author_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -566,9 +533,7 @@ "history_content_slug_unique": { "name": "history_content_slug_unique", "nullsNotDistinct": false, - "columns": [ - "slug" - ] + "columns": ["slug"] } }, "policies": {}, @@ -612,12 +577,8 @@ "name": "post_likes_post_id_posts_id_fk", "tableFrom": "post_likes", "tableTo": "posts", - "columnsFrom": [ - "post_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["post_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -625,12 +586,8 @@ "name": "post_likes_user_id_users_id_fk", "tableFrom": "post_likes", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -640,10 +597,7 @@ "post_likes_post_id_user_id_unique": { "name": "post_likes_post_id_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "post_id", - "user_id" - ] + "columns": ["post_id", "user_id"] } }, "policies": {}, @@ -719,12 +673,8 @@ "name": "posts_author_id_users_id_fk", "tableFrom": "posts", "tableTo": "users", - "columnsFrom": [ - "author_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["author_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -734,9 +684,7 @@ "posts_slug_unique": { "name": "posts_slug_unique", "nullsNotDistinct": false, - "columns": [ - "slug" - ] + "columns": ["slug"] } }, "policies": {}, @@ -826,16 +774,12 @@ "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": [ - "username" - ] + "columns": ["username"] }, "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -847,20 +791,12 @@ "public.booking_status": { "name": "booking_status", "schema": "public", - "values": [ - "pending", - "confirmed", - "cancelled" - ] + "values": ["pending", "confirmed", "cancelled"] }, "public.user_status": { "name": "user_status", "schema": "public", - "values": [ - "visitor", - "attendant", - "admin" - ] + "values": ["visitor", "attendant", "admin"] } }, "schemas": {}, @@ -873,4 +809,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6bc14df..33d84bb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..aa756d5 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,48 @@ +/** + * ESLint 9+ flat config for TypeScript and Node.js. + * Uses TypeScript parser for .ts files; recommended rules from @eslint/js and Prettier. + */ +const path = require("path"); +const js = require("@eslint/js"); +const prettier = require("eslint-config-prettier"); + +// Load TypeScript parser from project node_modules (works with pnpm) +let tsParser; +try { + tsParser = require(path.resolve(process.cwd(), "node_modules/@typescript-eslint/parser")); +} catch { + tsParser = null; +} + +module.exports = [ + js.configs.recommended, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { ecmaVersion: 2022, sourceType: "module" }, + globals: { + console: "readonly", + process: "readonly", + Buffer: "readonly", + __dirname: "readonly", + module: "readonly", + require: "readonly", + exports: "writable", + }, + }, + rules: { + "no-console": ["warn", { allow: ["warn", "error"] }], + }, + }, + prettier, + { + ignores: [ + "dist/**", + "node_modules/**", + "drizzle/**", + "**/*.js", + "config/openapi/**", + ], + }, +]; diff --git a/index.ts b/index.ts index 24be9f0..2cc8df4 100644 --- a/index.ts +++ b/index.ts @@ -1,69 +1,44 @@ -import express from "express"; +/** + * Entry point — validate env, create app, register routes, start server. + * App is built in a modular way so it can be tested without listening + * and domains can be split into microservices later. + */ +// Validate env first (throws and exits if invalid) +import { env } from "./config/env/env-validation"; +import { createApp } from "./app"; import { registerRoutes } from "./server/routes"; -import cors from "cors"; -import corsOptions from "./config/cors/cors-options"; -import dotenv from "dotenv"; import errorHandler from "./middlewares/errors/error-handler"; -import { getServerConfig } from "./server/utils/helper-function"; +import { logger } from "./config/logger/logger-config"; +const PORT = env.PORT; -dotenv.config(); - -const PORT = process.env.PORT || 5001; -const NODE_ENV = process.env.NODE_ENV || 'development'; - -const app = express(); -app.use(express.json()); -app.use(express.urlencoded({ extended: false })); -// cors -app.use(cors(corsOptions)); - -app.use((req, res, next) => { - const start = Date.now(); - const path = req.path; - let capturedJsonResponse: Record | undefined = undefined; - - const originalResJson = res.json; - res.json = function (bodyJson, ...args) { - capturedJsonResponse = bodyJson; - return originalResJson.apply(res, [bodyJson, ...args]); - }; - - res.on("finish", () => { - const duration = Date.now() - start; - if (path.startsWith("/api/v1")) { - let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`; - if (capturedJsonResponse) { - logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`; - } - - if (logLine.length > 80) { - logLine = logLine.slice(0, 79) + "…"; - } - - console.log(logLine); - } - }); - - next(); +// Log uncaught errors so nodemon shows why the process exited +process.on("uncaughtException", (err) => { + console.error("Uncaught exception:", err); + process.exit(1); +}); +process.on("unhandledRejection", (reason, promise) => { + console.error("Unhandled rejection at", promise, "reason:", reason); + process.exit(1); }); (async () => { - const serverConfig = getServerConfig(); - - const server = await registerRoutes("/api/v1", app); - - app.use(errorHandler); - - server.listen( - PORT, - () => { - console.log(`🚀 Server running in ${NODE_ENV} mode`); - console.log(`🌐 Server URL: ${serverConfig.url}`); - console.log(`📡 Server is Listening on ${serverConfig.host}:${serverConfig.port}`); - - } - ); + try { + const app = createApp(); + const server = await registerRoutes("/api/v1", app); + app.use(errorHandler); + + server.listen(PORT, () => { + logger.info("Server started successfully", { + mode: env.NODE_ENV, + port: PORT, + basePath: "/api/v1", + }); + }); + } catch (err) { + console.error("Startup error:", err); + process.exit(1); + } })(); -export default app; +export default createApp; diff --git a/jest.config.js b/jest.config.js index 86f88fb..a2129a9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,7 @@ const tsJestTransformCfg = createDefaultPreset().transform; /** @type {import("jest").Config} **/ module.exports = { testEnvironment: "node", + setupFiles: ["/jest.setup.js"], transform: { ...tsJestTransformCfg, }, diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..cb06926 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,8 @@ +// Ensure minimal env for tests (env-validation runs on first config import) +process.env.NODE_ENV = process.env.NODE_ENV || "test"; +process.env.DATABASE_URL = process.env.DATABASE_URL || "postgresql://localhost:5432/test_db"; +process.env.FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; +process.env.SESSION_SECRET = + process.env.SESSION_SECRET || "test-secret-at-least-32-characters-long"; +// In test, force a valid optional URL so env-validation never fails on API_PROD_URL +process.env.API_PROD_URL = "http://localhost:5001"; diff --git a/middlewares/errors/error-handler.ts b/middlewares/errors/error-handler.ts index 829707b..29dff62 100644 --- a/middlewares/errors/error-handler.ts +++ b/middlewares/errors/error-handler.ts @@ -1,83 +1,147 @@ -import { logEvents } from "../logger"; +/** + * Global error handler and AppError hierarchy. + * All API errors should use StandardErrorResponse; stack traces only in non-production. + */ +import { Request, Response, NextFunction } from "express"; +import { logger } from "../../config/logger/logger-config"; +import type { StandardErrorResponse } from "./error-response"; -const errorHandler = (err: any, req: any, res: any, next: any) => { - if (logEvents) - logEvents( - `${err.name}:${err.message}\t${req.method}\t${req.url}\t${req.headers.origin}`, 'errLog.log'); +/** Request-like shape for logging (method, path, requestId from middleware). */ +interface RequestLike { + method?: string; + path?: string; + requestId?: string; +} + +/** Response-like with status + json for sending error body. */ +interface ResponseLike { + status(code: number): { json(body: unknown): void }; +} + +/** Base app error with HTTP status; subclasses define statusCode. */ +export abstract class AppError extends Error { + abstract readonly statusCode: number; + constructor(message: string) { + super(message); + this.name = this.constructor.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} - console.error("\n\n hit error handler \n\n"); - const statusCode = res.statusCode ? res.statusCode : 500; // Server Error - res.status(statusCode).send({ - message: err.message, - stack: process.env.NODE_ENV === 'production' ? '🥞' : err.message, - method: req.method, - origin: req.url, - timestamp: new Date().toISOString(), - }); +/** Express error middleware: logs and sends StandardErrorResponse; never leaks stack in production. */ +const errorHandler = ( + err: unknown, + req: RequestLike, + res: Response & ResponseLike, + _next: NextFunction +): void => { + const statusCode = err instanceof AppError ? err.statusCode : 500; + const message = err instanceof Error ? err.message : "Internal server error"; + const path = typeof req.path === "string" ? req.path : "/"; - next(err); + logger.error("Request error", { + error: { name: err instanceof Error ? err.name : "Error", message }, + request: { method: req.method, path, requestId: req.requestId }, + statusCode, + }); + + const body: StandardErrorResponse = { + success: false, + error: { + message, + ...(process.env.NODE_ENV !== "production" && + err instanceof Error && + err.stack && { stack: err.stack }), + }, + ...(req.requestId && { requestId: req.requestId }), + timestamp: new Date().toISOString(), + path, + }; + res.status(statusCode).json(body); }; -// Custom error classes for better error handling -export class DatabaseError extends Error { - constructor(message: string, public originalError?: unknown) { - super(message); - this.name = 'DatabaseError'; - } +export class DatabaseError extends AppError { + readonly statusCode = 500; + constructor( + message: string, + public originalError?: unknown + ) { + super(message); + this.name = "DatabaseError"; + } } -export class NotFoundError extends Error { - constructor(resource: string, id: string) { - super(`${resource} with id ${id} not found`); - this.name = 'NotFoundError'; - } +export class NotFoundError extends AppError { + readonly statusCode = 404; + constructor(resource: string, id: string) { + super(`${resource} with id ${id} not found`); + this.name = "NotFoundError"; + } } -export class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'ValidationError'; - } +export class ValidationError extends AppError { + readonly statusCode = 400; + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } } -// Enhanced error handling wrapper -export const withErrorHandling = async ( - operation: () => Promise, - context: string, - options: { - preserveErrors?: boolean | Array Error>; - } = {} -): Promise => { - try { - return await operation(); - } catch (error) { - console.error(`\n\n💥 Error in ${context}:`, error); - - // Determine if we should preserve this error - const shouldPreserve = - options.preserveErrors === true || - (Array.isArray(options.preserveErrors) && - options.preserveErrors.some(ErrorType => error instanceof ErrorType)); - - if (shouldPreserve) { - throw error; // Re-throw original error - } +export class UnauthorizedError extends AppError { + readonly statusCode = 401; + constructor(message: string = "Authentication required") { + super(message); + this.name = "UnauthorizedError"; + } +} - // Handle specific database errors - if (error instanceof Error) { - if (error.message.includes('duplicate key')) { - throw new DatabaseError(`Duplicate entry in ${context}`, error); - } - if (error.message.includes('foreign key')) { - throw new DatabaseError(`Foreign key constraint violation in ${context}`, error); - } - if (error.message.includes('connection')) { - throw new DatabaseError(`Database connection error in ${context}`, error); - } - } +export class ForbiddenError extends AppError { + readonly statusCode = 403; + constructor(message: string = "Access denied") { + super(message); + this.name = "ForbiddenError"; + } +} - throw new DatabaseError(`Operation failed in ${context}`, error); +/** + * Wraps an async operation and maps known DB errors to AppError subclasses. + * Use preserveErrors to re-throw specific error types unchanged. + */ +export const withErrorHandling = async ( + operation: () => Promise, + context: string, + options: { + preserveErrors?: boolean | Array Error>; + } = {} +): Promise => { + try { + return await operation(); + } catch (error) { + if (process.env.NODE_ENV !== "test") { + console.error(`Error in ${context}:`, error); + } + const shouldPreserve = + options.preserveErrors === true || + (Array.isArray(options.preserveErrors) && + options.preserveErrors.some((ErrorType) => error instanceof ErrorType)); + if (shouldPreserve) { + throw error; + } + if (error instanceof Error) { + if (error.message.includes("duplicate key")) { + throw new DatabaseError(`Duplicate entry in ${context}`, error); + } + if (error.message.includes("foreign key")) { + throw new DatabaseError(`Foreign key constraint violation in ${context}`, error); + } + if (error.message.includes("connection")) { + throw new DatabaseError(`Database connection error in ${context}`, error); + } } + throw new DatabaseError(`Operation failed in ${context}`, error); + } }; +export { sendErrorResponse } from "./error-response"; +export type { StandardErrorResponse } from "./error-response"; export default errorHandler; diff --git a/middlewares/errors/error-response.ts b/middlewares/errors/error-response.ts new file mode 100644 index 0000000..035b126 --- /dev/null +++ b/middlewares/errors/error-response.ts @@ -0,0 +1,54 @@ +import type { Response } from "express"; + +/** + * Standard JSON error shape for all API error responses. + * Used by the global error handler and by sendErrorResponse() for one-off errors. + */ +export interface StandardErrorResponse { + success: false; + error: { + message: string; + code?: string; + details?: unknown; + stack?: string; + }; + requestId?: string; + timestamp: string; + path: string; +} + +export interface SendErrorOptions { + requestId?: string; + path?: string; + code?: string; + details?: unknown; + includeStack?: boolean; +} + +/** + * Send a standardized error response. Use for one-off errors in middleware or routes + * when not throwing an AppError. Prefer throwing AppError subclasses so the global + * handler can format the response. + */ +export function sendErrorResponse( + res: Response, + statusCode: number, + message: string, + options: SendErrorOptions = {} +): void { + const { requestId, path, code, details, includeStack } = options; + const err = res.locals?.error as Error | undefined; + const body: StandardErrorResponse = { + success: false, + error: { + message, + ...(code && { code }), + ...(details !== undefined && { details }), + ...(includeStack && err?.stack && { stack: err.stack }), + }, + ...(requestId && { requestId }), + timestamp: new Date().toISOString(), + path: path ?? res.req?.path ?? "/", + }; + res.status(statusCode).json(body); +} diff --git a/middlewares/global-rate-limit.ts b/middlewares/global-rate-limit.ts new file mode 100644 index 0000000..8599da8 --- /dev/null +++ b/middlewares/global-rate-limit.ts @@ -0,0 +1,28 @@ +/** + * Global API rate limiting — applies to all routes except health and API docs. + * Uses express-rate-limit; trust proxy in production for correct client IP. + */ +import rateLimit from "express-rate-limit"; +import { env } from "../config/env/env-validation"; + +const windowMs = 15 * 60 * 1000; // 15 minutes +const max = 200; // requests per IP per window + +export const globalRateLimiter = rateLimit({ + windowMs, + max, + message: { + success: false, + error: { message: "Too many requests from this IP, please try again later." }, + }, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => { + const path = req.path; + // Skip health and docs so load balancers and monitoring are not limited + if (path === "/api/v1/health" || path === "/api/v1/live" || path === "/api/v1/ready") return true; + if (path === "/api-docs" || path === "/api-docs.json" || path.startsWith("/api-docs/")) return true; + return false; + }, + ...(env.NODE_ENV === "test" && { max: 10000 }), +}); diff --git a/middlewares/logger.ts b/middlewares/logger.ts index 4791a77..64acf83 100644 --- a/middlewares/logger.ts +++ b/middlewares/logger.ts @@ -1,31 +1,55 @@ -import { format } from 'date-fns'; -import { v4 as UUIDV4 } from 'uuid'; +import { Request, Response, NextFunction } from "express"; +import { logger } from "../config/logger/logger-config"; -import fs from 'fs'; -import path from 'path'; +/** + * Request logging middleware + * Logs all incoming HTTP requests with structured data (includes requestId when available) + */ +export const requestLogger = (req: Request, res: Response, next: NextFunction) => { + const start = Date.now(); -const fsPromises = fs.promises; + // Capture response data + let responseBody: any; + const originalJson = res.json.bind(res); + res.json = function (body: any) { + responseBody = body; + return originalJson(body); + }; -const logEvents = async (message: string, logFileName: string) => { - const dateTime = `${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}`; - const logItem = `${dateTime}\t${UUIDV4()}\t${message}\n`; - const logPath = path.join(__dirname, '..', 'logs'); + // Log when response finishes + res.on("finish", () => { + const duration = Date.now() - start; + const logData = { + ...(req.requestId && { requestId: req.requestId }), + method: req.method, + path: req.path, + statusCode: res.statusCode, + duration: `${duration}ms`, + ip: req.ip, + userAgent: req.get("user-agent"), + origin: req.get("origin"), + contentLength: res.get("content-length"), + }; - try { - if (!fs.existsSync(logPath)) { - await fsPromises.mkdir(logPath); - } - await fsPromises.appendFile(path.join(__dirname, '..', 'logs', logFileName), logItem); - } catch (err) { - console.error(err); + // Log level based on status code + if (res.statusCode >= 500) { + logger.error("HTTP Request", logData); + } else if (res.statusCode >= 400) { + logger.warn("HTTP Request", logData); + } else { + logger.info("HTTP Request", logData); } + }); + + next(); }; -const logger = (req: any, res: any, next: any) => { - logEvents(`${req.method}\t${req.headers.origin}\t${req.url}`, 'reqLog.log'); - console.log(`${req.method} ${req.path}`); - next(); +/** + * Legacy logEvents function for backward compatibility + * @deprecated Use logger from config/logger/logger-config instead + */ +export const logEvents = async (message: string, logFileName: string) => { + logger.info(message, { logFile: logFileName }); }; -export default logger; -export { logEvents }; \ No newline at end of file +export default requestLogger; diff --git a/middlewares/login-limiter.ts b/middlewares/login-limiter.ts index 0e0422a..9c4387d 100644 --- a/middlewares/login-limiter.ts +++ b/middlewares/login-limiter.ts @@ -3,31 +3,31 @@ import { Request, Response, NextFunction } from "express"; // Importing the logEvents function from the logger module to log events // This function is used to log messages to a file, typically for error logging or monitoring purposes -import { logEvents } from './logger'; +import { logEvents } from "./logger"; const loginLimiter = rateLimit({ - windowMs: 60 * 1000, // 15 minutes - max: 3, // Limit each IP to 3 requests per windowMs - message: { - message: "Too many login attempts from this IP, please try again after 1 minutes", - }, - handler: (req: Request, res: Response, next: NextFunction, options: any) => { - logEvents( - `${options.message.message}\t${req.method}\t${req.headers.origin}\t${req.url}`, - "errLog.log" - ); - res.status(429).json({ - message: "Too many login attempts from this IP, please try again after 1 minutes", - }); - }, - standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers - legacyHeaders: false, // Disable the `X-RateLimit-*` headers - skipFailedRequests: true, // Skip failed requests - skipSuccessfulRequests: false, // Do not skip successful requests + windowMs: 60 * 1000, // 15 minutes + max: 3, // Limit each IP to 3 requests per windowMs + message: { + message: "Too many login attempts from this IP, please try again after 1 minutes", + }, + handler: (req: Request, res: Response, next: NextFunction, options: any) => { + logEvents( + `${options.message.message}\t${req.method}\t${req.headers.origin}\t${req.url}`, + "errLog.log" + ); + res.status(429).json({ + message: "Too many login attempts from this IP, please try again after 1 minutes", + }); + }, + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + skipFailedRequests: true, // Skip failed requests + skipSuccessfulRequests: false, // Do not skip successful requests }); export default loginLimiter; // This code is a middleware for an Express.js application that limits the number of login attempts from a single IP address. // It uses the `express-rate-limit` package to set a limit of 5 login attempts per 1 minutes. // If the limit is exceeded, it logs the event and sends a 429 status code with a message indicating that too many login attempts have been made. // The `logEvents` function is used to log the details of the request, including the method, origin, and URL, to a log file named "errLog.log". -// This is useful for preventing brute-force attacks on the login endpoint by limiting the number of attempts from a single IP address. \ No newline at end of file +// This is useful for preventing brute-force attacks on the login endpoint by limiting the number of attempts from a single IP address. diff --git a/middlewares/not-found.ts b/middlewares/not-found.ts new file mode 100644 index 0000000..199d9da --- /dev/null +++ b/middlewares/not-found.ts @@ -0,0 +1,16 @@ +/** + * 404 Not Found handler for API routes (Web API standard). + * Sends a consistent JSON error when no route matches under the API base path. + */ +import type { Request, Response, NextFunction } from "express"; +import { sendErrorResponse } from "./errors/error-response"; + +export function notFoundMiddleware(req: Request, res: Response, _next: NextFunction): void { + sendErrorResponse(res, 404, "Resource not found", { + path: req.originalUrl || req.path, + requestId: req.requestId, + code: "NOT_FOUND", + }); +} + +export default notFoundMiddleware; diff --git a/middlewares/request-id.ts b/middlewares/request-id.ts new file mode 100644 index 0000000..f21c7c2 --- /dev/null +++ b/middlewares/request-id.ts @@ -0,0 +1,36 @@ +import { Request, Response, NextFunction } from "express"; +import { v4 as uuidv4 } from "uuid"; + +/** + * Extend Express Request type to include requestId + */ +declare global { + namespace Express { + interface Request { + requestId?: string; + } + } +} + +/** + * Request ID middleware + * Generates a unique request ID for each incoming request + * and adds it to the request object and response headers + */ +export const requestIdMiddleware = (req: Request, res: Response, next: NextFunction): void => { + // Generate or use existing request ID (from X-Request-ID header if present) + const requestId = (req.headers["x-request-id"] as string) || uuidv4(); + + // Attach to request object + req.requestId = requestId; + + // Add to response headers for client tracking + res.setHeader("X-Request-ID", requestId); + + // Add to response locals for logging + res.locals.requestId = requestId; + + next(); +}; + +export default requestIdMiddleware; diff --git a/package.json b/package.json index a268436..23f925c 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,49 @@ { - "name": "rest-express", + "name": "museum-management-rest-api", "version": "1.0.0", "type": "commonjs", "license": "MIT", - "scripts": { - "dev": "nodemon --exec ts-node index.ts", - "start": "node dist/index.js", - "prebuild": "find node_modules -type d -name 'sqlite-core' -exec rm -rf {} +", - "build": "tsc", - "build:prod": "NODE_ENV=production tsc", - "check": "tsc --noEmit", - "lint": "eslint ./**/*.ts", - "test": "jest", - "test:watch": "jest --watch", - "db:push": "npx drizzle-kit push", - "db:seed": "tsx ./config/database/seed.ts", - "db:migrate": "npx drizzle-kit migrate", - "db:generate": "npx drizzle-kit generate", - "db:reset": "tsx ./config/database/reset-db.ts" -}, + "description": "A comprehensive RESTful API server for a digital museum platform, providing content management, user authentication, booking systems, and community forum features with real-time capabilities.", + "author": "Avom brice", + "repository": { + "type": "git", + "url": "https://github.com/frckbrice/museum-management-REST-API" + }, + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.0.0" + }, + "keywords": [ + "rest-api", + "express", + "typescript", + "museum", + "postgresql", + "drizzle-orm", + "openapi", + "swagger", + "websocket", + "session-auth" + ], + "scripts": { + "dev": "nodemon --exec tsx index.ts", + "start": "node dist/index.js", + "prebuild": "find node_modules -type d -name 'sqlite-core' -exec rm -rf {} +", + "build": "tsc", + "build:prod": "NODE_ENV=production tsc", + "check": "tsc --noEmit", + "format": "prettier --write \"**/*.{ts,js,json,md}\"", + "format:check": "prettier --check \"**/*.{ts,js,json,md}\"", + "lint": "eslint \"**/*.ts\" --ignore-pattern node_modules --ignore-pattern dist", + "test": "jest", + "validate": "pnpm run format:check && pnpm run lint && pnpm run check && pnpm run test && pnpm run build", + "test:watch": "jest --watch", + "db:push": "npx drizzle-kit push", + "db:seed": "tsx ./config/database/seed.ts", + "db:migrate": "npx drizzle-kit migrate", + "db:generate": "npx drizzle-kit generate", + "db:reset": "tsx ./config/database/reset-db.ts" + }, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@neondatabase/serverless": "^0.10.4", @@ -38,6 +63,9 @@ "express": "^4.21.2", "express-rate-limit": "^7.5.1", "express-session": "^1.18.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0", + "helmet": "^8.0.0", "multer": "^2.0.1", "multer-storage-cloudinary": "^4.0.0", "mysql2": "^3.14.1", @@ -45,27 +73,39 @@ "passport-local": "^1.0.0", "pg": "^8.16.2", "uuid": "^11.1.0", + "winston": "^3.15.0", "ws": "^8.18.0", "zod": "^3.23.8", "zod-validation-error": "^3.4.0" }, "devDependencies": { + "@eslint/js": "^9.0.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "8.57.0", + "eslint-config-prettier": "^9.1.0", + "prettier": "^3.2.5", "@types/bcrypt": "^5.0.2", "@types/connect-pg-simple": "^7.0.3", "@types/cors": "^2.8.19", "@types/express": "4.17.21", "@types/express-session": "^1.18.0", + "@types/helmet": "^4.0.0", "@types/jest": "^30.0.0", "@types/multer": "^1.4.13", "@types/node": "20.16.11", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38", - "@types/pg": "^8.15.4", + "@types/swagger-ui-express": "^4.1.6", + "@types/pg": "8.16.0", + "@types/supertest": "^6.0.2", + "@types/winston": "^2.4.4", "@types/ws": "^8.5.13", "drizzle-kit": "^0.27.1", "esbuild": "^0.24.0", "jest": "^30.0.3", "nodemon": "^3.1.10", + "supertest": "^7.0.0", "ts-jest": "^29.4.0", "ts-node": "^10.9.2", "tsx": "^4.19.1", @@ -74,4 +114,4 @@ "optionalDependencies": { "bufferutil": "^4.0.8" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5eadc92..6e5ebcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@jridgewell/trace-mapping': specifier: ^0.3.25 - version: 0.3.25 + version: 0.3.31 '@neondatabase/serverless': specifier: ^0.10.4 version: 0.10.4 @@ -22,22 +22,22 @@ importers: version: 6.0.0 bcryptjs: specifier: ^3.0.2 - version: 3.0.2 + version: 3.0.3 cloudinary: specifier: ^2.7.0 - version: 2.7.0 + version: 2.9.0 clsx: specifier: ^2.1.1 version: 2.1.1 cmdk: specifier: ^1.0.0 - version: 1.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) connect-pg-simple: specifier: ^10.0.0 version: 10.0.0 cors: specifier: ^2.8.5 - version: 2.8.5 + version: 2.8.6 date-fns: specifier: ^3.6.0 version: 3.6.0 @@ -46,31 +46,34 @@ importers: version: 16.6.1 drizzle-orm: specifier: ^0.38.4 - version: 0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.3)(react@19.1.0) + version: 0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0)(react@19.2.4) drizzle-seed: specifier: ^0.3.1 - version: 0.3.1(drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.3)(react@19.1.0)) + version: 0.3.1(drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0)(react@19.2.4)) drizzle-zod: specifier: ^0.6.0 - version: 0.6.1(drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.3)(react@19.1.0))(zod@3.25.67) + version: 0.6.1(drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0)(react@19.2.4))(zod@3.25.76) express: specifier: ^4.21.2 - version: 4.21.2 + version: 4.22.1 express-rate-limit: specifier: ^7.5.1 - version: 7.5.1(express@4.21.2) + version: 7.5.1(express@4.22.1) express-session: specifier: ^1.18.1 - version: 1.18.1 + version: 1.19.0 + helmet: + specifier: ^8.0.0 + version: 8.1.0 multer: specifier: ^2.0.1 - version: 2.0.1 + version: 2.0.2 multer-storage-cloudinary: specifier: ^4.0.0 - version: 4.0.0(cloudinary@2.7.0) + version: 4.0.0(cloudinary@2.9.0) mysql2: specifier: ^3.14.1 - version: 3.14.1 + version: 3.16.3 passport: specifier: ^0.7.0 version: 0.7.0 @@ -79,20 +82,32 @@ importers: version: 1.0.0 pg: specifier: ^8.16.2 - version: 8.16.3 + version: 8.18.0 + swagger-jsdoc: + specifier: ^6.2.8 + version: 6.2.8(openapi-types@12.1.3) + swagger-ui-express: + specifier: ^5.0.0 + version: 5.0.1(express@4.22.1) uuid: specifier: ^11.1.0 version: 11.1.0 + winston: + specifier: ^3.15.0 + version: 3.19.0 ws: specifier: ^8.18.0 - version: 8.18.3(bufferutil@4.0.9) + version: 8.19.0(bufferutil@4.1.0) zod: specifier: ^3.23.8 - version: 3.25.67 + version: 3.25.76 zod-validation-error: specifier: ^3.4.0 - version: 3.5.2(zod@3.25.67) + version: 3.5.4(zod@3.25.76) devDependencies: + '@eslint/js': + specifier: ^9.0.0 + version: 9.39.2 '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 @@ -108,6 +123,9 @@ importers: '@types/express-session': specifier: ^1.18.0 version: 1.18.2 + '@types/helmet': + specifier: ^4.0.0 + version: 4.0.0 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -125,97 +143,139 @@ importers: version: 1.0.38 '@types/pg': specifier: ^8.15.4 - version: 8.15.4 + version: 8.16.0 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + '@types/swagger-ui-express': + specifier: ^4.1.6 + version: 4.1.8 + '@types/winston': + specifier: ^2.4.4 + version: 2.4.4 '@types/ws': specifier: ^8.5.13 version: 8.18.1 + '@typescript-eslint/eslint-plugin': + specifier: ^7.0.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3) + '@typescript-eslint/parser': + specifier: ^7.0.0 + version: 7.18.0(eslint@8.57.0)(typescript@5.6.3) drizzle-kit: specifier: ^0.27.1 version: 0.27.2 esbuild: specifier: ^0.24.0 version: 0.24.2 + eslint: + specifier: 8.57.0 + version: 8.57.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.2(eslint@8.57.0) jest: specifier: ^30.0.3 - version: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + version: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) nodemon: specifier: ^3.1.10 - version: 3.1.10 + version: 3.1.11 + prettier: + specifier: ^3.2.5 + version: 3.8.1 + supertest: + specifier: ^7.0.0 + version: 7.2.2 ts-jest: specifier: ^29.4.0 - version: 29.4.0(@babel/core@7.27.7)(@jest/transform@30.0.2)(@jest/types@30.0.1)(babel-jest@30.0.2(@babel/core@7.27.7))(esbuild@0.24.2)(jest-util@30.0.2)(jest@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)))(typescript@5.6.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.24.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)))(typescript@5.6.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.16.11)(typescript@5.6.3) tsx: specifier: ^4.19.1 - version: 4.20.3 + version: 4.21.0 typescript: specifier: 5.6.3 version: 5.6.3 optionalDependencies: bufferutil: specifier: ^4.0.8 - version: 4.0.9 + version: 4.1.0 packages: - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} + '@apidevtools/json-schema-ref-parser@9.1.2': + resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@10.0.3': + resolution: {integrity: sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==} + peerDependencies: + openapi-types: '>=7' + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.27.7': - resolution: {integrity: sha512-xgu/ySj2mTiUFmdE9yCMfBxLp4DHd5DwmbbD05YAuICfodYT3VvRxbrh81LGQ/8UpSdtMdfKMn3KouYDX59DGQ==} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/core@7.27.7': - resolution: {integrity: sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.27.5': - resolution: {integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.27.3': - resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==} + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.27.6': - resolution: {integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.27.7': - resolution: {integrity: sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true @@ -240,8 +300,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -256,8 +316,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -304,42 +364,49 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.27.1': - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.27.7': - resolution: {integrity: sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw==} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.27.7': - resolution: {integrity: sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@emnapi/core@1.4.3': - resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.4.3': - resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - '@emnapi/wasi-threads@1.0.2': - resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==} + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} @@ -361,8 +428,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.5': - resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -385,8 +452,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.5': - resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -409,8 +476,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.5': - resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -433,8 +500,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.5': - resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -457,8 +524,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.5': - resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -481,8 +548,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.5': - resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -505,8 +572,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.5': - resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -529,8 +596,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.5': - resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -553,8 +620,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.5': - resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -577,8 +644,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.5': - resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -601,8 +668,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.5': - resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -625,8 +692,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.5': - resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -649,8 +716,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.5': - resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -673,8 +740,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.5': - resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -697,8 +764,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.5': - resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -721,8 +788,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.5': - resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -745,8 +812,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.5': - resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -757,8 +824,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.25.5': - resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -781,8 +848,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.5': - resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -793,8 +860,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.25.5': - resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -817,12 +884,18 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.5': - resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -841,8 +914,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.5': - resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -865,8 +938,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.5': - resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -889,8 +962,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.5': - resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -913,12 +986,47 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.5': - resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.0': + resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanwhocodes/config-array@0.11.14': + resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -931,12 +1039,12 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - '@jest/console@30.0.2': - resolution: {integrity: sha512-krGElPU0FipAqpVZ/BRZOy0MZh/ARdJ0Nj+PiH1ykFY1+VpBlYNLjdjVA5CFKxnKR6PFqFutO4Z7cdK9BlGiDA==} + '@jest/console@30.2.0': + resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/core@30.0.3': - resolution: {integrity: sha512-Mgs1N+NSHD3Fusl7bOq1jyxv1JDAUwjy+0DhVR93Q6xcBP9/bAQ+oZhXb5TTnP5sQzAHgb7ROCKQ2SnovtxYtg==} + '@jest/core@30.2.0': + resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -948,36 +1056,36 @@ packages: resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/environment@30.0.2': - resolution: {integrity: sha512-hRLhZRJNxBiOhxIKSq2UkrlhMt3/zVFQOAi5lvS8T9I03+kxsbflwHJEF+eXEYXCrRGRhHwECT7CDk6DyngsRA==} + '@jest/environment@30.2.0': + resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect-utils@30.0.3': - resolution: {integrity: sha512-SMtBvf2sfX2agcT0dA9pXwcUrKvOSDqBY4e4iRfT+Hya33XzV35YVg+98YQFErVGA/VR1Gto5Y2+A6G9LSQ3Yg==} + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect@30.0.3': - resolution: {integrity: sha512-73BVLqfCeWjYWPEQoYjiRZ4xuQRhQZU0WdgvbyXGRHItKQqg5e6mt2y1kVhzLSuZpmUnccZHbGynoaL7IcLU3A==} + '@jest/expect@30.2.0': + resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/fake-timers@30.0.2': - resolution: {integrity: sha512-jfx0Xg7l0gmphTY9UKm5RtH12BlLYj/2Plj6wXjVW5Era4FZKfXeIvwC67WX+4q8UCFxYS20IgnMcFBcEU0DtA==} + '@jest/fake-timers@30.2.0': + resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/get-type@30.0.1': - resolution: {integrity: sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==} + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/globals@30.0.3': - resolution: {integrity: sha512-fIduqNyYpMeeSr5iEAiMn15KxCzvrmxl7X7VwLDRGj7t5CoHtbF+7K3EvKk32mOUIJ4kIvFRlaixClMH2h/Vaw==} + '@jest/globals@30.2.0': + resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/pattern@30.0.1': resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/reporters@30.0.2': - resolution: {integrity: sha512-l4QzS/oKf57F8WtPZK+vvF4Io6ukplc6XgNFu4Hd/QxaLEO9f+8dSFzUua62Oe0HKlCUjKHpltKErAgDiMJKsA==} + '@jest/reporters@30.2.0': + resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -985,71 +1093,91 @@ packages: node-notifier: optional: true - '@jest/schemas@30.0.1': - resolution: {integrity: sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==} + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/snapshot-utils@30.0.1': - resolution: {integrity: sha512-6Dpv7vdtoRiISEFwYF8/c7LIvqXD7xDXtLPNzC2xqAfBznKip0MQM+rkseKwUPUpv2PJ7KW/YsnwWXrIL2xF+A==} + '@jest/snapshot-utils@30.2.0': + resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/source-map@30.0.1': resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-result@30.0.2': - resolution: {integrity: sha512-KKMuBKkkZYP/GfHMhI+cH2/P3+taMZS3qnqqiPC1UXZTJskkCS+YU/ILCtw5anw1+YsTulDHFpDo70mmCedW8w==} + '@jest/test-result@30.2.0': + resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-sequencer@30.0.2': - resolution: {integrity: sha512-fbyU5HPka0rkalZ3MXVvq0hwZY8dx3Y6SCqR64zRmh+xXlDeFl0IdL4l9e7vp4gxEXTYHbwLFA1D+WW5CucaSw==} + '@jest/test-sequencer@30.2.0': + resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/transform@30.0.2': - resolution: {integrity: sha512-kJIuhLMTxRF7sc0gPzPtCDib/V9KwW3I2U25b+lYCYMVqHHSrcZopS8J8H+znx9yixuFv+Iozl8raLt/4MoxrA==} + '@jest/transform@30.2.0': + resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/types@30.0.1': - resolution: {integrity: sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==} + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jridgewell/gen-mapping@0.3.8': - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} - engines: {node: '>=6.0.0'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@napi-rs/wasm-runtime@0.2.11': - resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} '@neondatabase/serverless@0.10.4': resolution: {integrity: sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@pkgr/core@0.2.7': - resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@radix-ui/primitive@1.1.2': - resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} @@ -1069,8 +1197,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.14': - resolution: {integrity: sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==} + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1082,8 +1210,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dismissable-layer@1.1.10': - resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==} + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1095,8 +1223,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-guards@1.1.2': - resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1139,8 +1267,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.4': - resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1165,6 +1293,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -1174,6 +1315,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1219,8 +1369,11 @@ packages: '@types/react': optional: true - '@sinclair/typebox@0.34.37': - resolution: {integrity: sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==} + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -1228,8 +1381,11 @@ packages: '@sinonjs/fake-timers@13.0.5': resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} - '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} '@tsconfig/node12@1.0.11': resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} @@ -1240,8 +1396,8 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.9.0': - resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1252,8 +1408,8 @@ packages: '@types/babel__template@7.4.4': resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - '@types/babel__traverse@7.20.7': - resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} '@types/bcrypt@5.0.2': resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} @@ -1270,11 +1426,14 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} - '@types/express-serve-static-core@4.19.6': - resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} '@types/express-session@1.18.2': resolution: {integrity: sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==} @@ -1282,6 +1441,10 @@ packages: '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + '@types/helmet@4.0.0': + resolution: {integrity: sha512-ONIn/nSNQA57yRge3oaMQESef/6QhoeX7llWeDli0UZIfz8TQMkfNPTXA8VnnyeA1WUjG2pGqdjEIueYonMdfQ==} + deprecated: This is a stub types definition. helmet provides its own type definitions, so you do not need this installed. + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1297,8 +1460,11 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} '@types/multer@1.4.13': resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} @@ -1318,8 +1484,8 @@ packages: '@types/pg@8.11.6': resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} - '@types/pg@8.15.4': - resolution: {integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -1327,119 +1493,193 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/send@0.17.5': - resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - '@types/serve-static@1.15.8': - resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/swagger-ui-express@4.1.8': + resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@types/winston@2.4.4': + resolution: {integrity: sha512-BVGCztsypW8EYwJ+Hq+QNYiT/MUyCif0ouBH+flrY66O5W+KIXAMML6E/0fJpm7VjIzgangahl5S03bJJQGrZw==} + deprecated: This is a stub types definition. winston provides its own type definitions, so you do not need this installed. + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@unrs/resolver-binding-android-arm-eabi@1.9.2': - resolution: {integrity: sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] os: [android] - '@unrs/resolver-binding-android-arm64@1.9.2': - resolution: {integrity: sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==} + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} cpu: [arm64] os: [android] - '@unrs/resolver-binding-darwin-arm64@1.9.2': - resolution: {integrity: sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==} + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} cpu: [arm64] os: [darwin] - '@unrs/resolver-binding-darwin-x64@1.9.2': - resolution: {integrity: sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==} + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} cpu: [x64] os: [darwin] - '@unrs/resolver-binding-freebsd-x64@1.9.2': - resolution: {integrity: sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==} + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} cpu: [x64] os: [freebsd] - '@unrs/resolver-binding-linux-arm-gnueabihf@1.9.2': - resolution: {integrity: sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==} + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm-musleabihf@1.9.2': - resolution: {integrity: sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==} + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm64-gnu@1.9.2': - resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==} + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - '@unrs/resolver-binding-linux-arm64-musl@1.9.2': - resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==} + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - '@unrs/resolver-binding-linux-ppc64-gnu@1.9.2': - resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==} + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - '@unrs/resolver-binding-linux-riscv64-gnu@1.9.2': - resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==} + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - '@unrs/resolver-binding-linux-riscv64-musl@1.9.2': - resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==} + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - '@unrs/resolver-binding-linux-s390x-gnu@1.9.2': - resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==} + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - '@unrs/resolver-binding-linux-x64-gnu@1.9.2': - resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==} + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - '@unrs/resolver-binding-linux-x64-musl@1.9.2': - resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==} + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - '@unrs/resolver-binding-wasm32-wasi@1.9.2': - resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==} + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@unrs/resolver-binding-win32-arm64-msvc@1.9.2': - resolution: {integrity: sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==} + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} cpu: [arm64] os: [win32] - '@unrs/resolver-binding-win32-ia32-msvc@1.9.2': - resolution: {integrity: sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==} + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} cpu: [ia32] os: [win32] - '@unrs/resolver-binding-win32-x64-msvc@1.9.2': - resolution: {integrity: sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==} + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} cpu: [x64] os: [win32] @@ -1447,6 +1687,11 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -1456,6 +1701,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1464,8 +1712,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.1.0: - resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -1476,8 +1724,8 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} anymatch@3.1.3: @@ -1493,6 +1741,9 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1500,55 +1751,69 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} - babel-jest@30.0.2: - resolution: {integrity: sha512-A5kqR1/EUTidM2YC2YMEUDP2+19ppgOwK0IAd9Swc3q2KqFb5f9PtRUXVeZcngu0z5mDMyZ9zH2huJZSOMLiTQ==} + 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} peerDependencies: - '@babel/core': ^7.11.0 + '@babel/core': ^7.11.0 || ^8.0.0-0 - babel-plugin-istanbul@7.0.0: - resolution: {integrity: sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==} + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} engines: {node: '>=12'} - babel-plugin-jest-hoist@30.0.1: - resolution: {integrity: sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==} + babel-plugin-jest-hoist@30.2.0: + resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - babel-preset-current-node-syntax@1.1.0: - resolution: {integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==} + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^7.0.0 || ^8.0.0-0 - babel-preset-jest@30.0.1: - resolution: {integrity: sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==} + babel-preset-jest@30.2.0: + resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: - '@babel/core': ^7.11.0 + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + bcrypt@6.0.0: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} - bcryptjs@3.0.2: - resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==} + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} hasBin: true binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} brace-expansion@1.1.12: @@ -1561,8 +1826,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.25.1: - resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1576,8 +1841,8 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - bufferutil@4.0.9: - resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} engines: {node: '>=6.14.2'} busboy@1.6.0: @@ -1596,6 +1861,9 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1608,8 +1876,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001726: - resolution: {integrity: sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==} + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1623,19 +1891,19 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - ci-info@4.2.0: - resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} - cjs-module-lexer@2.1.0: - resolution: {integrity: sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - cloudinary@2.7.0: - resolution: {integrity: sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==} + cloudinary@2.9.0: + resolution: {integrity: sha512-F3iKMOy4y0zy0bi5JBp94SC7HY7i/ImfTPSUV07iJmRzH1Iz8WavFfOlJTR1zvYM/xKGoiGZ3my/zy64In0IQQ==} engines: {node: '>=9'} clsx@2.1.1: @@ -1652,16 +1920,47 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - collect-v8-coverage@1.0.2: - resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@6.2.0: + resolution: {integrity: sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==} + engines: {node: '>= 6'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1684,22 +1983,22 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} create-require@1.1.1: @@ -1720,8 +2019,8 @@ packages: supports-color: optional: true - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -1729,18 +2028,25 @@ packages: supports-color: optional: true - dedent@1.6.0: - resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==} + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: babel-plugin-macros: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + 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'} @@ -1760,10 +2066,21 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -1888,13 +2205,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} - hasBin: true - - electron-to-chromium@1.5.177: - resolution: {integrity: sha512-7EH2G59nLsEMj97fpDuvVcYi6lwTcM1xuWw3PssD8xzboAW7zj7iB3COEEEATUfjLHrs5uKBLQT03V/8URx06g==} + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -1906,16 +2218,15 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} @@ -1929,6 +2240,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -1949,8 +2264,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.25.5: - resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true @@ -1965,11 +2280,55 @@ packages: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@9.1.2: + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.0: + resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -1982,8 +2341,8 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} - expect@30.0.3: - resolution: {integrity: sha512-HXg6NvK35/cSYZCUKAtmlgCFyqKM4frEPbzrav5hRqb0GMz0E0lS5hfzYjSaiaE5ysnp/qI2aeZkeyeIAOeXzQ==} + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} express-rate-limit@7.5.1: @@ -1992,39 +2351,81 @@ packages: peerDependencies: express: '>= 4.11' - express-session@1.18.1: - resolution: {integrity: sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==} + express-session@1.19.0: + resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==} engines: {node: '>= 0.8.0'} - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2075,24 +2476,37 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-tsconfig@4.10.1: - resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@7.1.6: + resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} @@ -2101,6 +2515,14 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -2113,15 +2535,23 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} human-signals@2.1.0: @@ -2132,13 +2562,21 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -2186,6 +2624,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -2212,28 +2654,23 @@ packages: resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} engines: {node: '>=10'} - istanbul-reports@3.1.7: - resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jake@10.9.2: - resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} - engines: {node: '>=10'} - hasBin: true - - jest-changed-files@30.0.2: - resolution: {integrity: sha512-Ius/iRST9FKfJI+I+kpiDh8JuUlAISnRszF9ixZDIqJF17FckH5sOzKC8a0wd0+D+8em5ADRHA5V5MnfeDk2WA==} + jest-changed-files@30.2.0: + resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-circus@30.0.3: - resolution: {integrity: sha512-rD9qq2V28OASJHJWDRVdhoBdRs6k3u3EmBzDYcyuMby8XCO3Ll1uq9kyqM41ZcC4fMiPulMVh3qMw0cBvDbnyg==} + jest-circus@30.2.0: + resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-cli@30.0.3: - resolution: {integrity: sha512-UWDSj0ayhumEAxpYRlqQLrssEi29kdQ+kddP94AuHhZknrE+mT0cR0J+zMHKFe9XPfX3dKQOc2TfWki3WhFTsA==} + jest-cli@30.2.0: + resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -2242,8 +2679,8 @@ packages: node-notifier: optional: true - jest-config@30.0.3: - resolution: {integrity: sha512-j0L4oRCtJwNyZktXIqwzEiDVQXBbQ4dqXuLD/TZdn++hXIcIfZmjHgrViEy5s/+j4HvITmAXbexVZpQ/jnr0bg==} + jest-config@30.2.0: + resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@types/node': '*' @@ -2257,40 +2694,40 @@ packages: ts-node: optional: true - jest-diff@30.0.3: - resolution: {integrity: sha512-Q1TAV0cUcBTic57SVnk/mug0/ASyAqtSIOkr7RAlxx97llRYsM74+E8N5WdGJUlwCKwgxPAkVjKh653h1+HA9A==} + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-docblock@30.0.1: - resolution: {integrity: sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==} + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-each@30.0.2: - resolution: {integrity: sha512-ZFRsTpe5FUWFQ9cWTMguCaiA6kkW5whccPy9JjD1ezxh+mJeqmz8naL8Fl/oSbNJv3rgB0x87WBIkA5CObIUZQ==} + jest-each@30.2.0: + resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-environment-node@30.0.2: - resolution: {integrity: sha512-XsGtZ0H+a70RsxAQkKuIh0D3ZlASXdZdhpOSBq9WRPq6lhe0IoQHGW0w9ZUaPiZQ/CpkIdprvlfV1QcXcvIQLQ==} + jest-environment-node@30.2.0: + resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-haste-map@30.0.2: - resolution: {integrity: sha512-telJBKpNLeCb4MaX+I5k496556Y2FiKR/QLZc0+MGBYl4k3OO0472drlV2LUe7c1Glng5HuAu+5GLYp//GpdOQ==} + jest-haste-map@30.2.0: + resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-leak-detector@30.0.2: - resolution: {integrity: sha512-U66sRrAYdALq+2qtKffBLDWsQ/XoNNs2Lcr83sc9lvE/hEpNafJlq2lXCPUBMNqamMECNxSIekLfe69qg4KMIQ==} + jest-leak-detector@30.2.0: + resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-matcher-utils@30.0.3: - resolution: {integrity: sha512-hMpVFGFOhYmIIRGJ0HgM9htC5qUiJ00famcc9sRFchJJiLZbbVKrAztcgE6VnXLRxA3XZ0bvNA7hQWh3oHXo/A==} + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-message-util@30.0.2: - resolution: {integrity: sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==} + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-mock@30.0.2: - resolution: {integrity: sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA==} + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-pnp-resolver@1.2.3: @@ -2306,44 +2743,44 @@ packages: resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve-dependencies@30.0.3: - resolution: {integrity: sha512-FlL6u7LiHbF0Oe27k7DHYMq2T2aNpPhxnNo75F7lEtu4A6sSw+TKkNNUGNcVckdFoL0RCWREJsC1HsKDwKRZzQ==} + jest-resolve-dependencies@30.2.0: + resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve@30.0.2: - resolution: {integrity: sha512-q/XT0XQvRemykZsvRopbG6FQUT6/ra+XV6rPijyjT6D0msOyCvR2A5PlWZLd+fH0U8XWKZfDiAgrUNDNX2BkCw==} + jest-resolve@30.2.0: + resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runner@30.0.3: - resolution: {integrity: sha512-CxYBzu9WStOBBXAKkLXGoUtNOWsiS1RRmUQb6SsdUdTcqVncOau7m8AJ4cW3Mz+YL1O9pOGPSYLyvl8HBdFmkQ==} + jest-runner@30.2.0: + resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runtime@30.0.3: - resolution: {integrity: sha512-Xjosq0C48G9XEQOtmgrjXJwPaUPaq3sPJwHDRaiC+5wi4ZWxO6Lx6jNkizK/0JmTulVNuxP8iYwt77LGnfg3/w==} + jest-runtime@30.2.0: + resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-snapshot@30.0.3: - resolution: {integrity: sha512-F05JCohd3OA1N9+5aEPXA6I0qOfZDGIx0zTq5Z4yMBg2i1p5ELfBusjYAWwTkC12c7dHcbyth4QAfQbS7cRjow==} + jest-snapshot@30.2.0: + resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-util@30.0.2: - resolution: {integrity: sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==} + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-validate@30.0.2: - resolution: {integrity: sha512-noOvul+SFER4RIvNAwGn6nmV2fXqBq67j+hKGHKGFCmK4ks/Iy1FSrqQNBLGKlu4ZZIRL6Kg1U72N1nxuRCrGQ==} + jest-validate@30.2.0: + resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-watcher@30.0.2: - resolution: {integrity: sha512-vYO5+E7jJuF+XmONr6CrbXdlYrgvZqtkn6pdkgjt/dU64UAdc0v1cAVaAeWtAfUUMScxNmnUjKPUMdCpNVASwg==} + jest-watcher@30.2.0: + resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-worker@30.0.2: - resolution: {integrity: sha512-RN1eQmx7qSLFA+o9pfJKlqViwL5wt+OL3Vff/A+/cPsmuw7NPwfgl33AP+/agRmHzPOFgXviRycR9kYwlcRQXg==} + jest-worker@30.2.0: + resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest@30.0.3: - resolution: {integrity: sha512-Uy8xfeE/WpT2ZLGDXQmaYNzw2v8NUKuYeKGtkS6sDxwsdQihdgYCXaKIYnph1h95DN5H35ubFDm0dfmsQnjn4Q==} + jest@30.2.0: + resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -2355,8 +2792,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsesc@3.1.0: @@ -2364,18 +2805,37 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2383,11 +2843,33 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -2398,12 +2880,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - - lru.min@1.1.2: - resolution: {integrity: sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} make-dir@4.0.0: @@ -2430,6 +2908,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -2451,6 +2933,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -2458,10 +2945,6 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -2488,20 +2971,20 @@ packages: peerDependencies: cloudinary: ^1.21.0 - multer@2.0.1: - resolution: {integrity: sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==} + multer@2.0.2: + resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} engines: {node: '>= 10.16.0'} - mysql2@3.14.1: - resolution: {integrity: sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==} + mysql2@3.16.3: + resolution: {integrity: sha512-+3XhQEt4FEFuvGV0JjIDj4eP2OT/oIj/54dYvqhblnSzlfcxVOuj+cd15Xz6hsG4HU1a+A5+BA9gm0618C4z7A==} engines: {node: '>= 8.0'} - named-placeholders@1.1.3: - resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} - engines: {node: '>=12.0.0'} + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} - napi-postinstall@0.2.5: - resolution: {integrity: sha512-kmsgUvCRIJohHjbZ3V8avP0I1Pekw329MVAMDzVxsrkjgdnqiwvMX5XwR+hWV66vsAtZ+iM+fVnq8RTQawUmCQ==} + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} hasBin: true @@ -2512,8 +2995,11 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - node-addon-api@8.4.0: - resolution: {integrity: sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} engines: {node: ^18 || ^20 || >= 21} node-gyp-build@4.8.4: @@ -2523,11 +3009,11 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nodemon@3.1.10: - resolution: {integrity: sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==} + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} engines: {node: '>=10'} hasBin: true @@ -2554,17 +3040,27 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.0.2: - resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -2577,6 +3073,10 @@ packages: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -2584,6 +3084,10 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -2623,14 +3127,18 @@ packages: path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} - pg-cloudflare@1.2.7: - resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.9.1: - resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -2640,24 +3148,24 @@ packages: resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} engines: {node: '>=4'} - pg-pool@3.10.1: - resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} peerDependencies: pg: '>=8.0' - pg-protocol@1.10.3: - resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg-types@4.0.2: - resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + pg-types@4.1.0: + resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==} engines: {node: '>=10'} - pg@8.16.3: - resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -2675,8 +3183,8 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} pirates@4.0.7: @@ -2695,8 +3203,8 @@ packages: resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} engines: {node: '>=12'} - postgres-bytea@1.0.0: - resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} engines: {node: '>=0.10.0'} postgres-bytea@3.0.0: @@ -2722,8 +3230,17 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - pretty-format@30.0.2: - resolution: {integrity: sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@30.2.0: + 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} proxy-addr@2.0.7: @@ -2733,24 +3250,23 @@ packages: pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + punycode@2.3.1: + 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==} - q@1.5.1: - resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} - engines: {node: '>=0.6.0', teleport: '>=0.2.0'} - deprecated: |- - You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) - - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + random-bytes@1.0.0: resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} engines: {node: '>= 0.8'} @@ -2759,14 +3275,14 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} - react-dom@19.1.0: - resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: - react: ^19.1.0 + react: ^19.2.4 react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -2781,8 +3297,8 @@ packages: '@types/react': optional: true - react-remove-scroll@2.7.1: - resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} engines: {node: '>=10'} peerDependencies: '@types/react': '*' @@ -2801,8 +3317,8 @@ packages: '@types/react': optional: true - react@19.1.0: - resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} readable-stream@3.6.2: @@ -2821,6 +3337,10 @@ packages: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2828,33 +3348,49 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.26.0: - resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true - send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} seq-queue@0.0.5: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} setprototypeof@1.2.0: @@ -2920,12 +3456,15 @@ packages: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} - statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} streamsearch@1.1.0: @@ -2951,8 +3490,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} strip-bom@4.0.0: @@ -2967,6 +3506,14 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -2979,14 +3526,38 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - synckit@0.11.8: - resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} + swagger-jsdoc@6.2.8: + resolution: {integrity: sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==} + engines: {node: '>=12.0.0'} + hasBin: true + + swagger-parser@10.0.3: + resolution: {integrity: sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==} + engines: {node: '>=10'} + + swagger-ui-dist@5.31.0: + resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -3002,8 +3573,18 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true - ts-jest@29.4.0: - resolution: {integrity: sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==} + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-jest@29.4.6: + resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -3046,15 +3627,23 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.20.3: - resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} hasBin: true - type-detect@4.0.8: + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -3075,6 +3664,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + uid-safe@2.1.5: resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} engines: {node: '>= 0.8'} @@ -3089,15 +3683,18 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unrs-resolver@1.9.2: - resolution: {integrity: sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==} + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -3136,6 +3733,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validator@13.15.26: + resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3148,6 +3749,21 @@ packages: engines: {node: '>= 8'} hasBin: true + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3163,8 +3779,8 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3186,6 +3802,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.0.0-1: + resolution: {integrity: sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==} + engines: {node: '>= 6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3202,227 +3822,258 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod-validation-error@3.5.2: - resolution: {integrity: sha512-mdi7YOLtram5dzJ5aDtm1AG9+mxRma1iaMrZdYIpFO7epdKBUwLHIxTF8CPDeCQ828zAXYtizrKlEJAtzgfgrw==} + z-schema@5.0.5: + resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} + engines: {node: '>=8.0.0'} + hasBin: true + + zod-validation-error@3.5.4: + resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} engines: {node: '>=18.0.0'} peerDependencies: - zod: ^3.25.0 + zod: ^3.24.4 - zod@3.25.67: - resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: - '@ampproject/remapping@2.3.0': + '@apidevtools/json-schema-ref-parser@9.1.2': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + call-me-maybe: 1.0.2 + js-yaml: 4.1.1 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@10.0.3(openapi-types@12.1.3)': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@apidevtools/json-schema-ref-parser': 9.1.2 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + '@jsdevtools/ono': 7.1.3 + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + z-schema: 5.0.5 - '@babel/code-frame@7.27.1': + '@babel/code-frame@7.29.0': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.27.7': {} + '@babel/compat-data@7.29.0': {} - '@babel/core@7.27.7': + '@babel/core@7.29.0': dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.27.7) - '@babel/helpers': 7.27.6 - '@babel/parser': 7.27.7 - '@babel/template': 7.27.2 - '@babel/traverse': 7.27.7 - '@babel/types': 7.27.7 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/generator@7.27.5': + '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.27.7 - '@babel/types': 7.27.7 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': + '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.27.7 + '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.1 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-module-imports@7.27.1': + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.27.7 - '@babel/types': 7.27.7 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.27.7)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.27.7 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.27.6': + '@babel/helpers@7.28.6': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.27.7 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 - '@babel/parser@7.27.7': + '@babel/parser@7.29.0': dependencies: - '@babel/types': 7.27.7 + '@babel/types': 7.29.0 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.27.7)': + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.27.7)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.27.7)': + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.27.7)': + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.27.7)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.27.7)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.27.7)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.27.7)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.27.7)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.27.7)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.27.7)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/template@7.27.2': + '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.27.7 - '@babel/types': 7.27.7 + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 - '@babel/traverse@7.27.7': + '@babel/traverse@7.29.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.5 - '@babel/parser': 7.27.7 - '@babel/template': 7.27.2 - '@babel/types': 7.27.7 - debug: 4.4.1(supports-color@5.5.0) - globals: 11.12.0 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/types@7.27.7': + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 '@bcoe/v8-coverage@0.2.3': {} + '@colors/colors@1.6.0': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + '@drizzle-team/brocli@0.10.2': {} - '@emnapi/core@1.4.3': + '@emnapi/core@1.8.1': dependencies: - '@emnapi/wasi-threads': 1.0.2 + '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.4.3': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.0.2': + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 optional: true @@ -3435,7 +4086,7 @@ snapshots: '@esbuild-kit/esm-loader@2.6.5': dependencies: '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.6 '@esbuild/aix-ppc64@0.19.12': optional: true @@ -3443,7 +4094,7 @@ snapshots: '@esbuild/aix-ppc64@0.24.2': optional: true - '@esbuild/aix-ppc64@0.25.5': + '@esbuild/aix-ppc64@0.27.3': optional: true '@esbuild/android-arm64@0.18.20': @@ -3455,7 +4106,7 @@ snapshots: '@esbuild/android-arm64@0.24.2': optional: true - '@esbuild/android-arm64@0.25.5': + '@esbuild/android-arm64@0.27.3': optional: true '@esbuild/android-arm@0.18.20': @@ -3467,7 +4118,7 @@ snapshots: '@esbuild/android-arm@0.24.2': optional: true - '@esbuild/android-arm@0.25.5': + '@esbuild/android-arm@0.27.3': optional: true '@esbuild/android-x64@0.18.20': @@ -3479,7 +4130,7 @@ snapshots: '@esbuild/android-x64@0.24.2': optional: true - '@esbuild/android-x64@0.25.5': + '@esbuild/android-x64@0.27.3': optional: true '@esbuild/darwin-arm64@0.18.20': @@ -3491,7 +4142,7 @@ snapshots: '@esbuild/darwin-arm64@0.24.2': optional: true - '@esbuild/darwin-arm64@0.25.5': + '@esbuild/darwin-arm64@0.27.3': optional: true '@esbuild/darwin-x64@0.18.20': @@ -3503,7 +4154,7 @@ snapshots: '@esbuild/darwin-x64@0.24.2': optional: true - '@esbuild/darwin-x64@0.25.5': + '@esbuild/darwin-x64@0.27.3': optional: true '@esbuild/freebsd-arm64@0.18.20': @@ -3515,7 +4166,7 @@ snapshots: '@esbuild/freebsd-arm64@0.24.2': optional: true - '@esbuild/freebsd-arm64@0.25.5': + '@esbuild/freebsd-arm64@0.27.3': optional: true '@esbuild/freebsd-x64@0.18.20': @@ -3527,7 +4178,7 @@ snapshots: '@esbuild/freebsd-x64@0.24.2': optional: true - '@esbuild/freebsd-x64@0.25.5': + '@esbuild/freebsd-x64@0.27.3': optional: true '@esbuild/linux-arm64@0.18.20': @@ -3539,7 +4190,7 @@ snapshots: '@esbuild/linux-arm64@0.24.2': optional: true - '@esbuild/linux-arm64@0.25.5': + '@esbuild/linux-arm64@0.27.3': optional: true '@esbuild/linux-arm@0.18.20': @@ -3551,7 +4202,7 @@ snapshots: '@esbuild/linux-arm@0.24.2': optional: true - '@esbuild/linux-arm@0.25.5': + '@esbuild/linux-arm@0.27.3': optional: true '@esbuild/linux-ia32@0.18.20': @@ -3563,7 +4214,7 @@ snapshots: '@esbuild/linux-ia32@0.24.2': optional: true - '@esbuild/linux-ia32@0.25.5': + '@esbuild/linux-ia32@0.27.3': optional: true '@esbuild/linux-loong64@0.18.20': @@ -3575,7 +4226,7 @@ snapshots: '@esbuild/linux-loong64@0.24.2': optional: true - '@esbuild/linux-loong64@0.25.5': + '@esbuild/linux-loong64@0.27.3': optional: true '@esbuild/linux-mips64el@0.18.20': @@ -3587,7 +4238,7 @@ snapshots: '@esbuild/linux-mips64el@0.24.2': optional: true - '@esbuild/linux-mips64el@0.25.5': + '@esbuild/linux-mips64el@0.27.3': optional: true '@esbuild/linux-ppc64@0.18.20': @@ -3599,7 +4250,7 @@ snapshots: '@esbuild/linux-ppc64@0.24.2': optional: true - '@esbuild/linux-ppc64@0.25.5': + '@esbuild/linux-ppc64@0.27.3': optional: true '@esbuild/linux-riscv64@0.18.20': @@ -3611,7 +4262,7 @@ snapshots: '@esbuild/linux-riscv64@0.24.2': optional: true - '@esbuild/linux-riscv64@0.25.5': + '@esbuild/linux-riscv64@0.27.3': optional: true '@esbuild/linux-s390x@0.18.20': @@ -3623,7 +4274,7 @@ snapshots: '@esbuild/linux-s390x@0.24.2': optional: true - '@esbuild/linux-s390x@0.25.5': + '@esbuild/linux-s390x@0.27.3': optional: true '@esbuild/linux-x64@0.18.20': @@ -3635,13 +4286,13 @@ snapshots: '@esbuild/linux-x64@0.24.2': optional: true - '@esbuild/linux-x64@0.25.5': + '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.24.2': optional: true - '@esbuild/netbsd-arm64@0.25.5': + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-x64@0.18.20': @@ -3653,13 +4304,13 @@ snapshots: '@esbuild/netbsd-x64@0.24.2': optional: true - '@esbuild/netbsd-x64@0.25.5': + '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.24.2': optional: true - '@esbuild/openbsd-arm64@0.25.5': + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-x64@0.18.20': @@ -3671,7 +4322,10 @@ snapshots: '@esbuild/openbsd-x64@0.24.2': optional: true - '@esbuild/openbsd-x64@0.25.5': + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/sunos-x64@0.18.20': @@ -3683,7 +4337,7 @@ snapshots: '@esbuild/sunos-x64@0.24.2': optional: true - '@esbuild/sunos-x64@0.25.5': + '@esbuild/sunos-x64@0.27.3': optional: true '@esbuild/win32-arm64@0.18.20': @@ -3695,7 +4349,7 @@ snapshots: '@esbuild/win32-arm64@0.24.2': optional: true - '@esbuild/win32-arm64@0.25.5': + '@esbuild/win32-arm64@0.27.3': optional: true '@esbuild/win32-ia32@0.18.20': @@ -3707,7 +4361,7 @@ snapshots: '@esbuild/win32-ia32@0.24.2': optional: true - '@esbuild/win32-ia32@0.25.5': + '@esbuild/win32-ia32@0.27.3': optional: true '@esbuild/win32-x64@0.18.20': @@ -3719,14 +4373,51 @@ snapshots: '@esbuild/win32-x64@0.24.2': optional: true - '@esbuild/win32-x64@0.25.5': + '@esbuild/win32-x64@0.27.3': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.0)': + dependencies: + eslint: 8.57.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3(supports-color@5.5.0) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.0': {} + + '@eslint/js@9.39.2': {} + + '@humanwhocodes/config-array@0.11.14': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -3736,49 +4427,49 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.1 + js-yaml: 3.14.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.3': {} - '@jest/console@30.0.2': + '@jest/console@30.2.0': dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 - jest-message-util: 30.0.2 - jest-util: 30.0.2 + jest-message-util: 30.2.0 + jest-util: 30.2.0 slash: 3.0.0 - '@jest/core@30.0.3(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3))': + '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3))': dependencies: - '@jest/console': 30.0.2 + '@jest/console': 30.2.0 '@jest/pattern': 30.0.1 - '@jest/reporters': 30.0.2 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 ansi-escapes: 4.3.2 chalk: 4.1.2 - ci-info: 4.2.0 + ci-info: 4.4.0 exit-x: 0.2.2 graceful-fs: 4.2.11 - jest-changed-files: 30.0.2 - jest-config: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) - jest-haste-map: 30.0.2 - jest-message-util: 30.0.2 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 jest-regex-util: 30.0.1 - jest-resolve: 30.0.2 - jest-resolve-dependencies: 30.0.3 - jest-runner: 30.0.3 - jest-runtime: 30.0.3 - jest-snapshot: 30.0.3 - jest-util: 30.0.2 - jest-validate: 30.0.2 - jest-watcher: 30.0.2 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 micromatch: 4.0.8 - pretty-format: 30.0.2 + pretty-format: 30.2.0 slash: 3.0.0 transitivePeerDependencies: - babel-plugin-macros @@ -3788,41 +4479,41 @@ snapshots: '@jest/diff-sequences@30.0.1': {} - '@jest/environment@30.0.2': + '@jest/environment@30.2.0': dependencies: - '@jest/fake-timers': 30.0.2 - '@jest/types': 30.0.1 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 - jest-mock: 30.0.2 + jest-mock: 30.2.0 - '@jest/expect-utils@30.0.3': + '@jest/expect-utils@30.2.0': dependencies: - '@jest/get-type': 30.0.1 + '@jest/get-type': 30.1.0 - '@jest/expect@30.0.3': + '@jest/expect@30.2.0': dependencies: - expect: 30.0.3 - jest-snapshot: 30.0.3 + expect: 30.2.0 + jest-snapshot: 30.2.0 transitivePeerDependencies: - supports-color - '@jest/fake-timers@30.0.2': + '@jest/fake-timers@30.2.0': dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@sinonjs/fake-timers': 13.0.5 '@types/node': 20.16.11 - jest-message-util: 30.0.2 - jest-mock: 30.0.2 - jest-util: 30.0.2 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 - '@jest/get-type@30.0.1': {} + '@jest/get-type@30.1.0': {} - '@jest/globals@30.0.3': + '@jest/globals@30.2.0': dependencies: - '@jest/environment': 30.0.2 - '@jest/expect': 30.0.3 - '@jest/types': 30.0.1 - jest-mock: 30.0.2 + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/types': 30.2.0 + jest-mock: 30.2.0 transitivePeerDependencies: - supports-color @@ -3831,78 +4522,78 @@ snapshots: '@types/node': 20.16.11 jest-regex-util: 30.0.1 - '@jest/reporters@30.0.2': + '@jest/reporters@30.2.0': dependencies: '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 30.0.2 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 - '@jridgewell/trace-mapping': 0.3.25 + '@jest/console': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 '@types/node': 20.16.11 chalk: 4.1.2 - collect-v8-coverage: 1.0.2 + collect-v8-coverage: 1.0.3 exit-x: 0.2.2 - glob: 10.4.5 + glob: 10.5.0 graceful-fs: 4.2.11 istanbul-lib-coverage: 3.2.2 istanbul-lib-instrument: 6.0.3 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.1.7 - jest-message-util: 30.0.2 - jest-util: 30.0.2 - jest-worker: 30.0.2 + istanbul-reports: 3.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + jest-worker: 30.2.0 slash: 3.0.0 string-length: 4.0.2 v8-to-istanbul: 9.3.0 transitivePeerDependencies: - supports-color - '@jest/schemas@30.0.1': + '@jest/schemas@30.0.5': dependencies: - '@sinclair/typebox': 0.34.37 + '@sinclair/typebox': 0.34.48 - '@jest/snapshot-utils@30.0.1': + '@jest/snapshot-utils@30.2.0': dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 chalk: 4.1.2 graceful-fs: 4.2.11 natural-compare: 1.4.0 '@jest/source-map@30.0.1': dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 callsites: 3.1.0 graceful-fs: 4.2.11 - '@jest/test-result@30.0.2': + '@jest/test-result@30.2.0': dependencies: - '@jest/console': 30.0.2 - '@jest/types': 30.0.1 + '@jest/console': 30.2.0 + '@jest/types': 30.2.0 '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.2 + collect-v8-coverage: 1.0.3 - '@jest/test-sequencer@30.0.2': + '@jest/test-sequencer@30.2.0': dependencies: - '@jest/test-result': 30.0.2 + '@jest/test-result': 30.2.0 graceful-fs: 4.2.11 - jest-haste-map: 30.0.2 + jest-haste-map: 30.2.0 slash: 3.0.0 - '@jest/transform@30.0.2': + '@jest/transform@30.2.0': dependencies: - '@babel/core': 7.27.7 - '@jest/types': 30.0.1 - '@jridgewell/trace-mapping': 0.3.25 - babel-plugin-istanbul: 7.0.0 + '@babel/core': 7.29.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 - jest-haste-map: 30.0.2 + jest-haste-map: 30.2.0 jest-regex-util: 30.0.1 - jest-util: 30.0.2 + jest-util: 30.2.0 micromatch: 4.0.8 pirates: 4.0.7 slash: 3.0.0 @@ -3910,160 +4601,195 @@ snapshots: transitivePeerDependencies: - supports-color - '@jest/types@30.0.1': + '@jest/types@30.2.0': dependencies: '@jest/pattern': 30.0.1 - '@jest/schemas': 30.0.1 + '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 '@types/node': 20.16.11 - '@types/yargs': 17.0.33 + '@types/yargs': 17.0.35 chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.8': + '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/set-array@1.2.1': {} + '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.25': + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jsdevtools/ono@7.1.3': {} - '@napi-rs/wasm-runtime@0.2.11': + '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.4.3 - '@emnapi/runtime': 1.4.3 - '@tybys/wasm-util': 0.9.0 + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 optional: true '@neondatabase/serverless@0.10.4': dependencies: '@types/pg': 8.11.6 + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@pkgjs/parseargs@0.11.0': optional: true - '@pkgr/core@0.2.7': {} + '@pkgr/core@0.2.9': {} - '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-compose-refs@1.1.2(react@19.1.0)': + '@radix-ui/react-compose-refs@1.1.2(react@19.2.4)': dependencies: - react: 19.1.0 + react: 19.2.4 - '@radix-ui/react-context@1.1.2(react@19.1.0)': + '@radix-ui/react-context@1.1.2(react@19.2.4)': dependencies: - react: 19.1.0 + react: 19.2.4 - '@radix-ui/react-dialog@1.1.14(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dialog@1.1.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(react@19.1.0) - '@radix-ui/react-context': 1.1.2(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.2(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(react@19.1.0) + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-context': 1.1.2(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(react@19.2.4) aria-hidden: 1.2.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.1(react@19.1.0) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(react@19.2.4) - '@radix-ui/react-dismissable-layer@1.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dismissable-layer@1.1.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.1.0) - '@radix-ui/react-use-escape-keydown': 1.1.1(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-focus-guards@1.1.2(react@19.1.0)': + '@radix-ui/react-focus-guards@1.1.3(react@19.2.4)': dependencies: - react: 19.1.0 + react: 19.2.4 - '@radix-ui/react-focus-scope@1.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-focus-scope@1.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-id@1.1.1(react@19.1.0)': + '@radix-ui/react-id@1.1.1(react@19.2.4)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 - '@radix-ui/react-portal@1.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-portal@1.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/react-primitive': 2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-presence@1.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-presence@1.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-primitive@2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-primitive@2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/react-slot': 1.2.3(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-slot@1.2.3(react@19.1.0)': + '@radix-ui/react-primitive@2.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-slot': 1.2.4(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-use-callback-ref@1.1.1(react@19.1.0)': + '@radix-ui/react-slot@1.2.3(react@19.2.4)': dependencies: - react: 19.1.0 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + react: 19.2.4 - '@radix-ui/react-use-controllable-state@1.2.2(react@19.1.0)': + '@radix-ui/react-slot@1.2.4(react@19.2.4)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + react: 19.2.4 - '@radix-ui/react-use-effect-event@0.0.2(react@19.1.0)': + '@radix-ui/react-use-callback-ref@1.1.1(react@19.2.4)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(react@19.1.0) - react: 19.1.0 + react: 19.2.4 - '@radix-ui/react-use-escape-keydown@1.1.1(react@19.1.0)': + '@radix-ui/react-use-controllable-state@1.2.2(react@19.2.4)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-use-effect-event': 0.0.2(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 - '@radix-ui/react-use-layout-effect@1.1.1(react@19.1.0)': + '@radix-ui/react-use-effect-event@0.0.2(react@19.2.4)': dependencies: - react: 19.1.0 + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 - '@sinclair/typebox@0.34.37': {} + '@radix-ui/react-use-escape-keydown@1.1.1(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.4) + react: 19.2.4 + + '@radix-ui/react-use-layout-effect@1.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@scarf/scarf@1.4.0': {} + + '@sinclair/typebox@0.34.48': {} '@sinonjs/commons@3.0.1': dependencies: @@ -4073,7 +4799,12 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@tsconfig/node10@1.0.11': {} + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -4081,31 +4812,31 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tybys/wasm-util@0.9.0': + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.27.7 - '@babel/types': 7.27.7 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.20.7 + '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.27.7 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.27.7 - '@babel/types': 7.27.7 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 - '@types/babel__traverse@7.20.7': + '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.27.7 + '@babel/types': 7.29.0 '@types/bcrypt@5.0.2': dependencies: @@ -4122,22 +4853,24 @@ snapshots: dependencies: '@types/express': 4.17.21 '@types/express-session': 1.18.2 - '@types/pg': 8.15.4 + '@types/pg': 8.16.0 '@types/connect@3.4.38': dependencies: '@types/node': 20.16.11 + '@types/cookiejar@2.1.5': {} + '@types/cors@2.8.19': dependencies: '@types/node': 20.16.11 - '@types/express-serve-static-core@4.19.6': + '@types/express-serve-static-core@4.19.8': dependencies: '@types/node': 20.16.11 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 - '@types/send': 0.17.5 + '@types/send': 1.2.1 '@types/express-session@1.18.2': dependencies: @@ -4146,9 +4879,13 @@ snapshots: '@types/express@4.17.21': dependencies: '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.6 + '@types/express-serve-static-core': 4.19.8 '@types/qs': 6.14.0 - '@types/serve-static': 1.15.8 + '@types/serve-static': 2.2.0 + + '@types/helmet@4.0.0': + dependencies: + helmet: 8.1.0 '@types/http-errors@2.0.5': {} @@ -4164,10 +4901,12 @@ snapshots: '@types/jest@30.0.0': dependencies: - expect: 30.0.3 - pretty-format: 30.0.2 + expect: 30.2.0 + pretty-format: 30.2.0 + + '@types/json-schema@7.0.15': {} - '@types/mime@1.3.5': {} + '@types/methods@1.1.4': {} '@types/multer@1.4.13': dependencies: @@ -4195,101 +4934,203 @@ snapshots: '@types/pg@8.11.6': dependencies: '@types/node': 20.16.11 - pg-protocol: 1.10.3 - pg-types: 4.0.2 + pg-protocol: 1.11.0 + pg-types: 4.1.0 - '@types/pg@8.15.4': + '@types/pg@8.16.0': dependencies: '@types/node': 20.16.11 - pg-protocol: 1.10.3 + pg-protocol: 1.11.0 pg-types: 2.2.0 '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} - '@types/send@0.17.5': + '@types/send@1.2.1': dependencies: - '@types/mime': 1.3.5 '@types/node': 20.16.11 - '@types/serve-static@1.15.8': + '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 '@types/node': 20.16.11 - '@types/send': 0.17.5 '@types/stack-utils@2.0.3': {} + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 20.16.11 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@types/swagger-ui-express@4.1.8': + dependencies: + '@types/express': 4.17.21 + '@types/serve-static': 2.2.0 + + '@types/triple-beam@1.3.5': {} + + '@types/winston@2.4.4': + dependencies: + winston: 3.19.0 + '@types/ws@8.18.1': dependencies: '@types/node': 20.16.11 '@types/yargs-parser@21.0.3': {} - '@types/yargs@17.0.33': + '@types/yargs@17.0.35': dependencies: '@types/yargs-parser': 21.0.3 + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.6.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.0)(typescript@5.6.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.0 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.3(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 8.57.0 + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.0)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.6.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 8.57.0 + ts-api-utils: 1.4.3(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.6.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3(supports-color@5.5.0) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.4 + ts-api-utils: 1.4.3(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@7.18.0(eslint@8.57.0)(typescript@5.6.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.0) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) + eslint: 8.57.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + '@ungap/structured-clone@1.3.0': {} - '@unrs/resolver-binding-android-arm-eabi@1.9.2': + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true - '@unrs/resolver-binding-android-arm64@1.9.2': + '@unrs/resolver-binding-android-arm64@1.11.1': optional: true - '@unrs/resolver-binding-darwin-arm64@1.9.2': + '@unrs/resolver-binding-darwin-arm64@1.11.1': optional: true - '@unrs/resolver-binding-darwin-x64@1.9.2': + '@unrs/resolver-binding-darwin-x64@1.11.1': optional: true - '@unrs/resolver-binding-freebsd-x64@1.9.2': + '@unrs/resolver-binding-freebsd-x64@1.11.1': optional: true - '@unrs/resolver-binding-linux-arm-gnueabihf@1.9.2': + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': optional: true - '@unrs/resolver-binding-linux-arm-musleabihf@1.9.2': + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': optional: true - '@unrs/resolver-binding-linux-arm64-gnu@1.9.2': + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-arm64-musl@1.9.2': + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': optional: true - '@unrs/resolver-binding-linux-ppc64-gnu@1.9.2': + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-riscv64-gnu@1.9.2': + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-riscv64-musl@1.9.2': + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': optional: true - '@unrs/resolver-binding-linux-s390x-gnu@1.9.2': + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-x64-gnu@1.9.2': + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-x64-musl@1.9.2': + '@unrs/resolver-binding-linux-x64-musl@1.11.1': optional: true - '@unrs/resolver-binding-wasm32-wasi@1.9.2': + '@unrs/resolver-binding-wasm32-wasi@1.11.1': dependencies: - '@napi-rs/wasm-runtime': 0.2.11 + '@napi-rs/wasm-runtime': 0.2.12 optional: true - '@unrs/resolver-binding-win32-arm64-msvc@1.9.2': + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': optional: true - '@unrs/resolver-binding-win32-ia32-msvc@1.9.2': + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': optional: true - '@unrs/resolver-binding-win32-x64-msvc@1.9.2': + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true accepts@1.3.8: @@ -4297,19 +5138,30 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn-walk@8.3.4: dependencies: acorn: 8.15.0 acorn@8.15.0: {} + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 ansi-regex@5.0.1: {} - ansi-regex@6.1.0: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: @@ -4317,7 +5169,7 @@ snapshots: ansi-styles@5.2.0: {} - ansi-styles@6.2.1: {} + ansi-styles@6.2.3: {} anymatch@3.1.3: dependencies: @@ -4332,32 +5184,40 @@ snapshots: dependencies: sprintf-js: 1.0.3 + argparse@2.0.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 array-flatten@1.1.1: {} + array-union@2.1.0: {} + + asap@2.0.6: {} + async@3.2.6: {} + asynckit@0.4.0: {} + aws-ssl-profiles@1.1.2: {} - babel-jest@30.0.2(@babel/core@7.27.7): + babel-jest@30.2.0(@babel/core@7.29.0): dependencies: - '@babel/core': 7.27.7 - '@jest/transform': 30.0.2 + '@babel/core': 7.29.0 + '@jest/transform': 30.2.0 '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 7.0.0 - babel-preset-jest: 30.0.1(@babel/core@7.27.7) + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.2.0(@babel/core@7.29.0) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 transitivePeerDependencies: - supports-color - babel-plugin-istanbul@7.0.0: + babel-plugin-istanbul@7.0.1: dependencies: - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 6.0.3 @@ -4365,60 +5225,60 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-jest-hoist@30.0.1: + babel-plugin-jest-hoist@30.2.0: dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.27.7 '@types/babel__core': 7.20.5 - babel-preset-current-node-syntax@1.1.0(@babel/core@7.27.7): - dependencies: - '@babel/core': 7.27.7 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.27.7) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.27.7) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.27.7) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.27.7) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.27.7) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.27.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.27.7) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.27.7) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.27.7) - - babel-preset-jest@30.0.1(@babel/core@7.27.7): - dependencies: - '@babel/core': 7.27.7 - babel-plugin-jest-hoist: 30.0.1 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.27.7) + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-jest@30.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) balanced-match@1.0.2: {} + baseline-browser-mapping@2.9.19: {} + bcrypt@6.0.0: dependencies: - node-addon-api: 8.4.0 + node-addon-api: 8.5.0 node-gyp-build: 4.8.4 - bcryptjs@3.0.2: {} + bcryptjs@3.0.3: {} binary-extensions@2.3.0: {} - body-parser@1.20.3: + body-parser@1.20.4: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 2.6.9 depd: 2.0.0 destroy: 1.2.0 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 + qs: 6.14.1 + raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 transitivePeerDependencies: @@ -4437,12 +5297,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.25.1: + browserslist@4.28.1: dependencies: - caniuse-lite: 1.0.30001726 - electron-to-chromium: 1.5.177 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.25.1) + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) bs-logger@0.2.6: dependencies: @@ -4454,7 +5315,7 @@ snapshots: buffer-from@1.1.2: {} - bufferutil@4.0.9: + bufferutil@4.1.0: dependencies: node-gyp-build: 4.8.4 optional: true @@ -4475,13 +5336,15 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + call-me-maybe@1.0.2: {} + callsites@3.1.0: {} camelcase@5.3.1: {} camelcase@6.3.0: {} - caniuse-lite@1.0.30001726: {} + caniuse-lite@1.0.30001769: {} chalk@4.1.2: dependencies: @@ -4502,9 +5365,9 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - ci-info@4.2.0: {} + ci-info@4.4.0: {} - cjs-module-lexer@2.1.0: {} + cjs-module-lexer@2.2.0: {} cliui@8.0.1: dependencies: @@ -4512,35 +5375,60 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - cloudinary@2.7.0: + cloudinary@2.9.0: dependencies: - lodash: 4.17.21 - q: 1.5.1 + lodash: 4.17.23 clsx@2.1.1: {} - cmdk@1.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + cmdk@1.1.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(react@19.1.0) - '@radix-ui/react-dialog': 1.1.14(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(react@19.2.4) + '@radix-ui/react-primitive': 2.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) transitivePeerDependencies: - '@types/react' - '@types/react-dom' co@4.6.0: {} - collect-v8-coverage@1.0.2: {} + collect-v8-coverage@1.0.3: {} color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + color-name@1.1.4: {} + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@6.2.0: {} + + commander@9.5.0: + optional: true + + component-emitter@1.3.1: {} + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -4552,7 +5440,7 @@ snapshots: connect-pg-simple@10.0.0: dependencies: - pg: 8.16.3 + pg: 8.18.0 transitivePeerDependencies: - pg-native @@ -4564,15 +5452,15 @@ snapshots: convert-source-map@2.0.0: {} - cookie-signature@1.0.6: {} - cookie-signature@1.0.7: {} - cookie@0.7.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} - cors@2.8.5: + cookiejar@2.1.4: {} + + cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 @@ -4591,16 +5479,20 @@ snapshots: dependencies: ms: 2.0.0 - debug@4.4.1(supports-color@5.5.0): + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 5.5.0 - dedent@1.6.0: {} + dedent@1.7.1: {} + + deep-is@0.1.4: {} deepmerge@4.3.1: {} + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} @@ -4611,7 +5503,20 @@ snapshots: detect-node-es@1.1.0: {} - diff@4.0.2: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff@4.0.4: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 dotenv@16.6.1: {} @@ -4624,24 +5529,24 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.3)(react@19.1.0): + drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0)(react@19.2.4): optionalDependencies: '@neondatabase/serverless': 0.10.4 - '@types/pg': 8.15.4 - mysql2: 3.14.1 - pg: 8.16.3 - react: 19.1.0 + '@types/pg': 8.16.0 + mysql2: 3.16.3 + pg: 8.18.0 + react: 19.2.4 - drizzle-seed@0.3.1(drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.3)(react@19.1.0)): + drizzle-seed@0.3.1(drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0)(react@19.2.4)): dependencies: pure-rand: 6.1.0 optionalDependencies: - drizzle-orm: 0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.3)(react@19.1.0) + drizzle-orm: 0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0)(react@19.2.4) - drizzle-zod@0.6.1(drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.3)(react@19.1.0))(zod@3.25.67): + drizzle-zod@0.6.1(drizzle-orm@0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0)(react@19.2.4))(zod@3.25.76): dependencies: - drizzle-orm: 0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.3)(react@19.1.0) - zod: 3.25.67 + drizzle-orm: 0.38.4(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0)(react@19.2.4) + zod: 3.25.76 dunder-proto@1.0.1: dependencies: @@ -4653,11 +5558,7 @@ snapshots: ee-first@1.1.1: {} - ejs@3.1.10: - dependencies: - jake: 10.9.2 - - electron-to-chromium@1.5.177: {} + electron-to-chromium@1.5.286: {} emittery@0.13.1: {} @@ -4665,11 +5566,11 @@ snapshots: emoji-regex@9.2.2: {} - encodeurl@1.0.2: {} + enabled@2.0.0: {} encodeurl@2.0.0: {} - error-ex@1.3.2: + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4681,16 +5582,23 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild-register@3.6.0(esbuild@0.19.12): dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) esbuild: 0.19.12 transitivePeerDependencies: - supports-color esbuild-register@3.6.0(esbuild@0.24.2): dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) esbuild: 0.24.2 transitivePeerDependencies: - supports-color @@ -4775,33 +5683,34 @@ snapshots: '@esbuild/win32-ia32': 0.24.2 '@esbuild/win32-x64': 0.24.2 - esbuild@0.25.5: + esbuild@0.27.3: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.5 - '@esbuild/android-arm': 0.25.5 - '@esbuild/android-arm64': 0.25.5 - '@esbuild/android-x64': 0.25.5 - '@esbuild/darwin-arm64': 0.25.5 - '@esbuild/darwin-x64': 0.25.5 - '@esbuild/freebsd-arm64': 0.25.5 - '@esbuild/freebsd-x64': 0.25.5 - '@esbuild/linux-arm': 0.25.5 - '@esbuild/linux-arm64': 0.25.5 - '@esbuild/linux-ia32': 0.25.5 - '@esbuild/linux-loong64': 0.25.5 - '@esbuild/linux-mips64el': 0.25.5 - '@esbuild/linux-ppc64': 0.25.5 - '@esbuild/linux-riscv64': 0.25.5 - '@esbuild/linux-s390x': 0.25.5 - '@esbuild/linux-x64': 0.25.5 - '@esbuild/netbsd-arm64': 0.25.5 - '@esbuild/netbsd-x64': 0.25.5 - '@esbuild/openbsd-arm64': 0.25.5 - '@esbuild/openbsd-x64': 0.25.5 - '@esbuild/sunos-x64': 0.25.5 - '@esbuild/win32-arm64': 0.25.5 - '@esbuild/win32-ia32': 0.25.5 - '@esbuild/win32-x64': 0.25.5 + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 escalade@3.2.0: {} @@ -4809,8 +5718,82 @@ snapshots: escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@9.1.2(eslint@8.57.0): + dependencies: + eslint: 8.57.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.0 + '@humanwhocodes/config-array': 0.11.14 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + etag@1.8.1: {} execa@5.1.1: @@ -4827,90 +5810,110 @@ snapshots: exit-x@0.2.2: {} - expect@30.0.3: + expect@30.2.0: dependencies: - '@jest/expect-utils': 30.0.3 - '@jest/get-type': 30.0.1 - jest-matcher-utils: 30.0.3 - jest-message-util: 30.0.2 - jest-mock: 30.0.2 - jest-util: 30.0.2 + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 - express-rate-limit@7.5.1(express@4.21.2): + express-rate-limit@7.5.1(express@4.22.1): dependencies: - express: 4.21.2 + express: 4.22.1 - express-session@1.18.1: + express-session@1.19.0: dependencies: cookie: 0.7.2 cookie-signature: 1.0.7 debug: 2.6.9 depd: 2.0.0 - on-headers: 1.0.2 + on-headers: 1.1.0 parseurl: 1.3.3 safe-buffer: 5.2.1 uid-safe: 2.1.5 transitivePeerDependencies: - supports-color - express@4.21.2: + express@4.22.1: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.3 + body-parser: 1.20.4 content-disposition: 0.5.4 content-type: 1.0.5 - cookie: 0.7.1 - cookie-signature: 1.0.6 + cookie: 0.7.2 + cookie-signature: 1.0.7 debug: 2.6.9 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.1 + finalhandler: 1.3.2 fresh: 0.5.2 - http-errors: 2.0.0 + http-errors: 2.0.1 merge-descriptors: 1.0.3 methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.14.1 range-parser: 1.2.1 safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 + send: 0.19.2 + serve-static: 1.16.3 setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 type-is: 1.6.18 utils-merge: 1.0.1 vary: 1.1.2 transitivePeerDependencies: - supports-color + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fb-watchman@2.0.2: dependencies: bser: 2.1.1 - filelist@1.0.4: + fecha@4.2.3: {} + + file-entry-cache@6.0.1: dependencies: - minimatch: 5.1.6 + flat-cache: 3.2.0 fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.1: + finalhandler@1.3.2: dependencies: debug: 2.6.9 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 unpipe: 1.0.0 transitivePeerDependencies: - supports-color @@ -4920,11 +5923,40 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + fn.name@1.1.0: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -4968,7 +6000,7 @@ snapshots: get-stream@6.0.1: {} - get-tsconfig@4.10.1: + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -4976,7 +6008,11 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.4.5: + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 @@ -4985,6 +6021,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@7.1.6: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -4994,30 +6039,58 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 - globals@11.12.0: {} + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 gopd@1.2.0: {} graceful-fs@4.2.11: {} + graphemer@1.4.0: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@3.0.0: {} has-flag@4.0.0: {} has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + html-escaper@2.0.2: {} - http-errors@2.0.0: + http-errors@2.0.1: dependencies: depd: 2.0.0 inherits: 2.0.4 setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 toidentifier: 1.0.1 human-signals@2.1.0: {} @@ -5026,12 +6099,19 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.6.3: + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 ignore-by-default@1.0.1: {} + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -5066,6 +6146,8 @@ snapshots: is-number@7.0.0: {} + is-path-inside@3.0.3: {} + is-property@1.0.2: {} is-stream@2.0.1: {} @@ -5076,11 +6158,11 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: - '@babel/core': 7.27.7 - '@babel/parser': 7.27.7 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.2 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -5092,13 +6174,13 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.1(supports-color@5.5.0) + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color - istanbul-reports@3.1.7: + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 @@ -5109,38 +6191,31 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jake@10.9.2: - dependencies: - async: 3.2.6 - chalk: 4.1.2 - filelist: 1.0.4 - minimatch: 3.1.2 - - jest-changed-files@30.0.2: + jest-changed-files@30.2.0: dependencies: execa: 5.1.1 - jest-util: 30.0.2 + jest-util: 30.2.0 p-limit: 3.1.0 - jest-circus@30.0.3: + jest-circus@30.2.0: dependencies: - '@jest/environment': 30.0.2 - '@jest/expect': 30.0.3 - '@jest/test-result': 30.0.2 - '@jest/types': 30.0.1 + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 co: 4.6.0 - dedent: 1.6.0 + dedent: 1.7.1 is-generator-fn: 2.1.0 - jest-each: 30.0.2 - jest-matcher-utils: 30.0.3 - jest-message-util: 30.0.2 - jest-runtime: 30.0.3 - jest-snapshot: 30.0.3 - jest-util: 30.0.2 + jest-each: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 p-limit: 3.1.0 - pretty-format: 30.0.2 + pretty-format: 30.2.0 pure-rand: 7.0.1 slash: 3.0.0 stack-utils: 2.0.6 @@ -5148,17 +6223,17 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): + jest-cli@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): dependencies: - '@jest/core': 30.0.3(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) - '@jest/test-result': 30.0.2 - '@jest/types': 30.0.1 + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) - jest-util: 30.0.2 - jest-validate: 30.0.2 + jest-config: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + jest-util: 30.2.0 + jest-validate: 30.2.0 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' @@ -5167,30 +6242,30 @@ snapshots: - supports-color - ts-node - jest-config@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): + jest-config@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): dependencies: - '@babel/core': 7.27.7 - '@jest/get-type': 30.0.1 + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.0.2 - '@jest/types': 30.0.1 - babel-jest: 30.0.2(@babel/core@7.27.7) + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.29.0) chalk: 4.1.2 - ci-info: 4.2.0 + ci-info: 4.4.0 deepmerge: 4.3.1 - glob: 10.4.5 + glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.0.3 - jest-docblock: 30.0.1 - jest-environment-node: 30.0.2 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 jest-regex-util: 30.0.1 - jest-resolve: 30.0.2 - jest-runner: 30.0.3 - jest-util: 30.0.2 - jest-validate: 30.0.2 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 micromatch: 4.0.8 parse-json: 5.2.0 - pretty-format: 30.0.2 + pretty-format: 30.2.0 slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: @@ -5201,227 +6276,227 @@ snapshots: - babel-plugin-macros - supports-color - jest-diff@30.0.3: + jest-diff@30.2.0: dependencies: '@jest/diff-sequences': 30.0.1 - '@jest/get-type': 30.0.1 + '@jest/get-type': 30.1.0 chalk: 4.1.2 - pretty-format: 30.0.2 + pretty-format: 30.2.0 - jest-docblock@30.0.1: + jest-docblock@30.2.0: dependencies: detect-newline: 3.1.0 - jest-each@30.0.2: + jest-each@30.2.0: dependencies: - '@jest/get-type': 30.0.1 - '@jest/types': 30.0.1 + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 chalk: 4.1.2 - jest-util: 30.0.2 - pretty-format: 30.0.2 + jest-util: 30.2.0 + pretty-format: 30.2.0 - jest-environment-node@30.0.2: + jest-environment-node@30.2.0: dependencies: - '@jest/environment': 30.0.2 - '@jest/fake-timers': 30.0.2 - '@jest/types': 30.0.1 + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 - jest-mock: 30.0.2 - jest-util: 30.0.2 - jest-validate: 30.0.2 + jest-mock: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 - jest-haste-map@30.0.2: + jest-haste-map@30.2.0: dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@types/node': 20.16.11 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 jest-regex-util: 30.0.1 - jest-util: 30.0.2 - jest-worker: 30.0.2 + jest-util: 30.2.0 + jest-worker: 30.2.0 micromatch: 4.0.8 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 - jest-leak-detector@30.0.2: + jest-leak-detector@30.2.0: dependencies: - '@jest/get-type': 30.0.1 - pretty-format: 30.0.2 + '@jest/get-type': 30.1.0 + pretty-format: 30.2.0 - jest-matcher-utils@30.0.3: + jest-matcher-utils@30.2.0: dependencies: - '@jest/get-type': 30.0.1 + '@jest/get-type': 30.1.0 chalk: 4.1.2 - jest-diff: 30.0.3 - pretty-format: 30.0.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 - jest-message-util@30.0.2: + jest-message-util@30.2.0: dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 30.0.1 + '@babel/code-frame': 7.29.0 + '@jest/types': 30.2.0 '@types/stack-utils': 2.0.3 chalk: 4.1.2 graceful-fs: 4.2.11 micromatch: 4.0.8 - pretty-format: 30.0.2 + pretty-format: 30.2.0 slash: 3.0.0 stack-utils: 2.0.6 - jest-mock@30.0.2: + jest-mock@30.2.0: dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@types/node': 20.16.11 - jest-util: 30.0.2 + jest-util: 30.2.0 - jest-pnp-resolver@1.2.3(jest-resolve@30.0.2): + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): optionalDependencies: - jest-resolve: 30.0.2 + jest-resolve: 30.2.0 jest-regex-util@30.0.1: {} - jest-resolve-dependencies@30.0.3: + jest-resolve-dependencies@30.2.0: dependencies: jest-regex-util: 30.0.1 - jest-snapshot: 30.0.3 + jest-snapshot: 30.2.0 transitivePeerDependencies: - supports-color - jest-resolve@30.0.2: + jest-resolve@30.2.0: dependencies: chalk: 4.1.2 graceful-fs: 4.2.11 - jest-haste-map: 30.0.2 - jest-pnp-resolver: 1.2.3(jest-resolve@30.0.2) - jest-util: 30.0.2 - jest-validate: 30.0.2 + jest-haste-map: 30.2.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.2.0) + jest-util: 30.2.0 + jest-validate: 30.2.0 slash: 3.0.0 - unrs-resolver: 1.9.2 + unrs-resolver: 1.11.1 - jest-runner@30.0.3: + jest-runner@30.2.0: dependencies: - '@jest/console': 30.0.2 - '@jest/environment': 30.0.2 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 + '@jest/console': 30.2.0 + '@jest/environment': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 graceful-fs: 4.2.11 - jest-docblock: 30.0.1 - jest-environment-node: 30.0.2 - jest-haste-map: 30.0.2 - jest-leak-detector: 30.0.2 - jest-message-util: 30.0.2 - jest-resolve: 30.0.2 - jest-runtime: 30.0.3 - jest-util: 30.0.2 - jest-watcher: 30.0.2 - jest-worker: 30.0.2 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-haste-map: 30.2.0 + jest-leak-detector: 30.2.0 + jest-message-util: 30.2.0 + jest-resolve: 30.2.0 + jest-runtime: 30.2.0 + jest-util: 30.2.0 + jest-watcher: 30.2.0 + jest-worker: 30.2.0 p-limit: 3.1.0 source-map-support: 0.5.13 transitivePeerDependencies: - supports-color - jest-runtime@30.0.3: + jest-runtime@30.2.0: dependencies: - '@jest/environment': 30.0.2 - '@jest/fake-timers': 30.0.2 - '@jest/globals': 30.0.3 + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/globals': 30.2.0 '@jest/source-map': 30.0.1 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 - cjs-module-lexer: 2.1.0 - collect-v8-coverage: 1.0.2 - glob: 10.4.5 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 graceful-fs: 4.2.11 - jest-haste-map: 30.0.2 - jest-message-util: 30.0.2 - jest-mock: 30.0.2 + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 jest-regex-util: 30.0.1 - jest-resolve: 30.0.2 - jest-snapshot: 30.0.3 - jest-util: 30.0.2 + jest-resolve: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: - supports-color - jest-snapshot@30.0.3: - dependencies: - '@babel/core': 7.27.7 - '@babel/generator': 7.27.5 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.7) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.7) - '@babel/types': 7.27.7 - '@jest/expect-utils': 30.0.3 - '@jest/get-type': 30.0.1 - '@jest/snapshot-utils': 30.0.1 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.27.7) + jest-snapshot@30.2.0: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) chalk: 4.1.2 - expect: 30.0.3 + expect: 30.2.0 graceful-fs: 4.2.11 - jest-diff: 30.0.3 - jest-matcher-utils: 30.0.3 - jest-message-util: 30.0.2 - jest-util: 30.0.2 - pretty-format: 30.0.2 - semver: 7.7.2 - synckit: 0.11.8 + jest-diff: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + pretty-format: 30.2.0 + semver: 7.7.4 + synckit: 0.11.12 transitivePeerDependencies: - supports-color - jest-util@30.0.2: + jest-util@30.2.0: dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 - ci-info: 4.2.0 + ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.2 + picomatch: 4.0.3 - jest-validate@30.0.2: + jest-validate@30.2.0: dependencies: - '@jest/get-type': 30.0.1 - '@jest/types': 30.0.1 + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 camelcase: 6.3.0 chalk: 4.1.2 leven: 3.1.0 - pretty-format: 30.0.2 + pretty-format: 30.2.0 - jest-watcher@30.0.2: + jest-watcher@30.2.0: dependencies: - '@jest/test-result': 30.0.2 - '@jest/types': 30.0.1 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 - jest-util: 30.0.2 + jest-util: 30.2.0 string-length: 4.0.2 - jest-worker@30.0.2: + jest-worker@30.2.0: dependencies: '@types/node': 20.16.11 '@ungap/structured-clone': 1.3.0 - jest-util: 30.0.2 + jest-util: 30.2.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): + jest@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): dependencies: - '@jest/core': 30.0.3(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) - '@jest/types': 30.0.1 + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + '@jest/types': 30.2.0 import-local: 3.2.0 - jest-cli: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + jest-cli: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -5431,28 +6506,70 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.1: + js-yaml@3.14.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} + json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kuler@2.0.0: {} + leven@3.1.0: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lines-and-columns@1.2.4: {} locate-path@5.0.0: dependencies: p-locate: 4.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.get@4.4.2: {} + + lodash.isequal@4.5.0: {} + lodash.memoize@4.1.2: {} - lodash@4.17.21: {} + lodash.merge@4.6.2: {} + + lodash.mergewith@4.6.2: {} + + lodash@4.17.23: {} + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 long@5.3.2: {} @@ -5462,13 +6579,11 @@ snapshots: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} - - lru.min@1.1.2: {} + lru.min@1.1.4: {} make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 make-error@1.3.6: {} @@ -5484,6 +6599,8 @@ snapshots: merge-stream@2.0.0: {} + merge2@1.4.1: {} + methods@1.1.2: {} micromatch@4.0.8: @@ -5499,16 +6616,14 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mimic-fn@2.1.0: {} minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 - minimatch@5.1.6: - dependencies: - brace-expansion: 2.0.2 - minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -5525,11 +6640,11 @@ snapshots: ms@2.1.3: {} - multer-storage-cloudinary@4.0.0(cloudinary@2.7.0): + multer-storage-cloudinary@4.0.0(cloudinary@2.9.0): dependencies: - cloudinary: 2.7.0 + cloudinary: 2.9.0 - multer@2.0.1: + multer@2.0.2: dependencies: append-field: 1.0.0 busboy: 1.6.0 @@ -5539,44 +6654,46 @@ snapshots: type-is: 1.6.18 xtend: 4.0.2 - mysql2@3.14.1: + mysql2@3.16.3: dependencies: aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 - iconv-lite: 0.6.3 + iconv-lite: 0.7.2 long: 5.3.2 - lru.min: 1.1.2 - named-placeholders: 1.1.3 + lru.min: 1.1.4 + named-placeholders: 1.1.6 seq-queue: 0.0.5 sqlstring: 2.3.3 - named-placeholders@1.1.3: + named-placeholders@1.1.6: dependencies: - lru-cache: 7.18.3 + lru.min: 1.1.4 - napi-postinstall@0.2.5: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} negotiator@0.6.3: {} - node-addon-api@8.4.0: {} + neo-async@2.6.2: {} + + node-addon-api@8.5.0: {} node-gyp-build@4.8.4: {} node-int64@0.4.0: {} - node-releases@2.0.19: {} + node-releases@2.0.27: {} - nodemon@3.1.10: + nodemon@3.1.11: dependencies: chokidar: 3.6.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) ignore-by-default: 1.0.1 minimatch: 3.1.2 pstree.remy: 1.1.8 - semver: 7.7.2 + semver: 7.7.4 simple-update-notifier: 2.0.0 supports-color: 5.5.0 touch: 3.1.1 @@ -5598,16 +6715,31 @@ snapshots: dependencies: ee-first: 1.1.1 - on-headers@1.0.2: {} + on-headers@1.1.0: {} once@1.4.0: dependencies: wrappy: 1.0.2 + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 + openapi-types@12.1.3: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -5620,14 +6752,22 @@ snapshots: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.2 + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -5658,32 +6798,34 @@ snapshots: path-to-regexp@0.1.12: {} + path-type@4.0.0: {} + pause@0.0.1: {} - pg-cloudflare@1.2.7: + pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.9.1: {} + pg-connection-string@2.11.0: {} pg-int8@1.0.1: {} pg-numeric@1.0.2: {} - pg-pool@3.10.1(pg@8.16.3): + pg-pool@3.11.0(pg@8.18.0): dependencies: - pg: 8.16.3 + pg: 8.18.0 - pg-protocol@1.10.3: {} + pg-protocol@1.11.0: {} pg-types@2.2.0: dependencies: pg-int8: 1.0.1 postgres-array: 2.0.0 - postgres-bytea: 1.0.0 + postgres-bytea: 1.0.1 postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg-types@4.0.2: + pg-types@4.1.0: dependencies: pg-int8: 1.0.1 pg-numeric: 1.0.2 @@ -5693,15 +6835,15 @@ snapshots: postgres-interval: 3.0.0 postgres-range: 1.1.4 - pg@8.16.3: + pg@8.18.0: dependencies: - pg-connection-string: 2.9.1 - pg-pool: 3.10.1(pg@8.16.3) - pg-protocol: 1.10.3 + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: - pg-cloudflare: 1.2.7 + pg-cloudflare: 1.3.0 pgpass@1.0.5: dependencies: @@ -5711,7 +6853,7 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.2: {} + picomatch@4.0.3: {} pirates@4.0.7: {} @@ -5723,7 +6865,7 @@ snapshots: postgres-array@3.0.4: {} - postgres-bytea@1.0.0: {} + postgres-bytea@1.0.1: {} postgres-bytea@3.0.0: dependencies: @@ -5741,9 +6883,13 @@ snapshots: postgres-range@1.1.4: {} - pretty-format@30.0.2: + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + + pretty-format@30.2.0: dependencies: - '@jest/schemas': 30.0.1 + '@jest/schemas': 30.0.5 ansi-styles: 5.2.0 react-is: 18.3.1 @@ -5754,56 +6900,58 @@ snapshots: pstree.remy@1.1.8: {} + punycode@2.3.1: {} + pure-rand@6.1.0: {} pure-rand@7.0.1: {} - q@1.5.1: {} - - qs@6.13.0: + qs@6.14.1: dependencies: side-channel: 1.1.0 + queue-microtask@1.2.3: {} + random-bytes@1.0.0: {} range-parser@1.2.1: {} - raw-body@2.5.2: + raw-body@2.5.3: dependencies: bytes: 3.1.2 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 unpipe: 1.0.0 - react-dom@19.1.0(react@19.1.0): + react-dom@19.2.4(react@19.2.4): dependencies: - react: 19.1.0 - scheduler: 0.26.0 + react: 19.2.4 + scheduler: 0.27.0 react-is@18.3.1: {} - react-remove-scroll-bar@2.3.8(react@19.1.0): + react-remove-scroll-bar@2.3.8(react@19.2.4): dependencies: - react: 19.1.0 - react-style-singleton: 2.2.3(react@19.1.0) + react: 19.2.4 + react-style-singleton: 2.2.3(react@19.2.4) tslib: 2.8.1 - react-remove-scroll@2.7.1(react@19.1.0): + react-remove-scroll@2.7.2(react@19.2.4): dependencies: - react: 19.1.0 - react-remove-scroll-bar: 2.3.8(react@19.1.0) - react-style-singleton: 2.2.3(react@19.1.0) + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(react@19.2.4) + react-style-singleton: 2.2.3(react@19.2.4) tslib: 2.8.1 - use-callback-ref: 1.3.3(react@19.1.0) - use-sidecar: 1.1.3(react@19.1.0) + use-callback-ref: 1.3.3(react@19.2.4) + use-sidecar: 1.1.3(react@19.2.4) - react-style-singleton@2.2.3(react@19.1.0): + react-style-singleton@2.2.3(react@19.2.4): dependencies: get-nonce: 1.0.1 - react: 19.1.0 + react: 19.2.4 tslib: 2.8.1 - react@19.1.0: {} + react@19.2.4: {} readable-stream@3.6.2: dependencies: @@ -5821,46 +6969,60 @@ snapshots: dependencies: resolve-from: 5.0.0 + resolve-from@4.0.0: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} - scheduler@0.26.0: {} + scheduler@0.27.0: {} semver@6.3.1: {} - semver@7.7.2: {} + semver@7.7.4: {} - send@0.19.0: + send@0.19.2: dependencies: debug: 2.6.9 depd: 2.0.0 destroy: 1.2.0 - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 fresh: 0.5.2 - http-errors: 2.0.0 + http-errors: 2.0.1 mime: 1.6.0 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color seq-queue@0.0.5: {} - serve-static@1.16.2: + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.0 + send: 0.19.2 transitivePeerDependencies: - supports-color @@ -5906,7 +7068,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 slash@3.0.0: {} @@ -5928,11 +7090,13 @@ snapshots: sqlstring@2.3.3: {} + stack-trace@0.0.10: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 - statuses@2.0.1: {} + statuses@2.0.2: {} streamsearch@1.1.0: {} @@ -5951,7 +7115,7 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 string_decoder@1.3.0: dependencies: @@ -5961,9 +7125,9 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.0: + strip-ansi@7.1.2: dependencies: - ansi-regex: 6.1.0 + ansi-regex: 6.2.2 strip-bom@4.0.0: {} @@ -5971,6 +7135,28 @@ snapshots: strip-json-comments@3.1.1: {} + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@5.5.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.1 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -5983,9 +7169,35 @@ snapshots: dependencies: has-flag: 4.0.0 - synckit@0.11.8: + swagger-jsdoc@6.2.8(openapi-types@12.1.3): + dependencies: + commander: 6.2.0 + doctrine: 3.0.0 + glob: 7.1.6 + lodash.mergewith: 4.6.2 + swagger-parser: 10.0.3(openapi-types@12.1.3) + yaml: 2.0.0-1 + transitivePeerDependencies: + - openapi-types + + swagger-parser@10.0.3(openapi-types@12.1.3): + dependencies: + '@apidevtools/swagger-parser': 10.0.3(openapi-types@12.1.3) + transitivePeerDependencies: + - openapi-types + + swagger-ui-dist@5.31.0: dependencies: - '@pkgr/core': 0.2.7 + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@4.22.1): + dependencies: + express: 4.22.1 + swagger-ui-dist: 5.31.0 + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 test-exclude@6.0.0: dependencies: @@ -5993,6 +7205,10 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + text-hex@1.0.0: {} + + text-table@0.2.0: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -6003,31 +7219,37 @@ snapshots: touch@3.1.1: {} - ts-jest@29.4.0(@babel/core@7.27.7)(@jest/transform@30.0.2)(@jest/types@30.0.1)(babel-jest@30.0.2(@babel/core@7.27.7))(esbuild@0.24.2)(jest-util@30.0.2)(jest@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)))(typescript@5.6.3): + triple-beam@1.4.1: {} + + ts-api-utils@1.4.3(typescript@5.6.3): + dependencies: + typescript: 5.6.3 + + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.24.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)))(typescript@5.6.3): dependencies: bs-logger: 0.2.6 - ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + handlebars: 4.7.8 + jest: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.24.2))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.7.2 + semver: 7.7.4 type-fest: 4.41.0 typescript: 5.6.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.27.7 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 - babel-jest: 30.0.2(@babel/core@7.27.7) + '@babel/core': 7.29.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.29.0) esbuild: 0.24.2 - jest-util: 30.0.2 + jest-util: 30.2.0 ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 + '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 @@ -6036,7 +7258,7 @@ snapshots: acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 - diff: 4.0.2 + diff: 4.0.4 make-error: 1.3.6 typescript: 5.6.3 v8-compile-cache-lib: 3.0.1 @@ -6044,15 +7266,21 @@ snapshots: tslib@2.8.1: {} - tsx@4.20.3: + tsx@4.21.0: dependencies: - esbuild: 0.25.5 - get-tsconfig: 4.10.1 + esbuild: 0.27.3 + get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-detect@4.0.8: {} + type-fest@0.20.2: {} + type-fest@0.21.3: {} type-fest@4.41.0: {} @@ -6066,6 +7294,9 @@ snapshots: typescript@5.6.3: {} + uglify-js@3.19.3: + optional: true + uid-safe@2.1.5: dependencies: random-bytes: 1.0.0 @@ -6076,45 +7307,49 @@ snapshots: unpipe@1.0.0: {} - unrs-resolver@1.9.2: + unrs-resolver@1.11.1: dependencies: - napi-postinstall: 0.2.5 + napi-postinstall: 0.3.4 optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.9.2 - '@unrs/resolver-binding-android-arm64': 1.9.2 - '@unrs/resolver-binding-darwin-arm64': 1.9.2 - '@unrs/resolver-binding-darwin-x64': 1.9.2 - '@unrs/resolver-binding-freebsd-x64': 1.9.2 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.9.2 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.9.2 - '@unrs/resolver-binding-linux-arm64-gnu': 1.9.2 - '@unrs/resolver-binding-linux-arm64-musl': 1.9.2 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.9.2 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.9.2 - '@unrs/resolver-binding-linux-riscv64-musl': 1.9.2 - '@unrs/resolver-binding-linux-s390x-gnu': 1.9.2 - '@unrs/resolver-binding-linux-x64-gnu': 1.9.2 - '@unrs/resolver-binding-linux-x64-musl': 1.9.2 - '@unrs/resolver-binding-wasm32-wasi': 1.9.2 - '@unrs/resolver-binding-win32-arm64-msvc': 1.9.2 - '@unrs/resolver-binding-win32-ia32-msvc': 1.9.2 - '@unrs/resolver-binding-win32-x64-msvc': 1.9.2 - - update-browserslist-db@1.1.3(browserslist@4.25.1): - dependencies: - browserslist: 4.25.1 + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 - use-callback-ref@1.3.3(react@19.1.0): + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(react@19.2.4): dependencies: - react: 19.1.0 + react: 19.2.4 tslib: 2.8.1 - use-sidecar@1.1.3(react@19.1.0): + use-sidecar@1.1.3(react@19.2.4): dependencies: detect-node-es: 1.1.0 - react: 19.1.0 + react: 19.2.4 tslib: 2.8.1 util-deprecate@1.0.2: {} @@ -6127,10 +7362,12 @@ snapshots: v8-to-istanbul@9.3.0: dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validator@13.15.26: {} + vary@1.1.2: {} walker@1.0.8: @@ -6141,6 +7378,30 @@ snapshots: dependencies: isexe: 2.0.0 + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -6149,9 +7410,9 @@ snapshots: wrap-ansi@8.1.0: dependencies: - ansi-styles: 6.2.1 + ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 wrappy@1.0.2: {} @@ -6160,9 +7421,9 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 - ws@8.18.3(bufferutil@4.0.9): + ws@8.19.0(bufferutil@4.1.0): optionalDependencies: - bufferutil: 4.0.9 + bufferutil: 4.1.0 xtend@4.0.2: {} @@ -6170,6 +7431,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.0.0-1: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -6186,8 +7449,16 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@3.5.2(zod@3.25.67): + z-schema@5.0.5: + dependencies: + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + validator: 13.15.26 + optionalDependencies: + commander: 9.5.0 + + zod-validation-error@3.5.4(zod@3.25.76): dependencies: - zod: 3.25.67 + zod: 3.25.76 - zod@3.25.67: {} + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..9316573 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "." + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..24753ad --- /dev/null +++ b/public/index.html @@ -0,0 +1,347 @@ + + + + + + Museum Management API — Badagry Heritage + + + + + + +
+ + +
+ REST API +

Museum Management API

+

Content, bookings, gallery, forum, and real-time features for the digital museum platform.

+ +
+
+ +
+
+

API features

+

Explore the main capabilities of the API. Full reference is available in the interactive docs.

+
+
+ History and archives +
+

History

+

Manage history content and heritage narratives with full CRUD and structured data.

+ GET / POST / PUT / DELETE /api/v1/histories + Try in API docs → +
+
+
+ Gallery and exhibitions +
+

Gallery

+

Gallery items and exhibitions with media uploads and metadata.

+ GET / POST /api/v1/galleries + Try in API docs → +
+
+
+ Bookings and visits +
+

Bookings

+

Visit bookings with real-time updates and availability.

+ GET / POST / PATCH /api/v1/bookings + Try in API docs → +
+
+
+ Community forum +
+

Forum

+

Posts, comments, and likes with WebSocket support for live updates.

+ GET / POST /api/v1/posts + Try in API docs → +
+
+
+ Contact and support +
+

Contact

+

Contact form and message handling with admin management.

+ GET / POST / PATCH /api/v1/contact_messages + Try in API docs → +
+
+
+ Authentication +
+

Auth & users

+

Register, login, session-based auth, and user profiles.

+ /api/v1/register, /login, /users + Try in API docs → +
+
+
+
+
+ + + + diff --git a/server/controllers/__tests__/booking.controller.test.ts b/server/controllers/__tests__/booking.controller.test.ts new file mode 100644 index 0000000..1870950 --- /dev/null +++ b/server/controllers/__tests__/booking.controller.test.ts @@ -0,0 +1,93 @@ +/** + * Unit tests for server/controllers/booking.controller.ts. + * Covers getBookingById, getBookingsByUserId, updateBookingStatus with mocked bookingService. + */ +import { Request, Response } from "express"; +import { bookingController } from "../booking.controller"; + +jest.mock("../../services", () => ({ + bookingService: { + getBookingById: jest.fn(), + getBookingsByUserId: jest.fn(), + getAllBookings: jest.fn(), + updateBookingStatus: jest.fn(), + }, +})); + +const bookingService = require("../../services").bookingService; + +describe("BookingController", () => { + let req: Partial; + let res: Partial; + let jsonMock: jest.Mock; + let statusMock: jest.Mock; + + beforeEach(() => { + jsonMock = jest.fn(); + statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + req = { params: {}, query: {}, body: {} }; + res = { status: statusMock, json: jsonMock }; + jest.clearAllMocks(); + }); + + describe("getBookingById", () => { + it("returns booking when found", async () => { + const mockBooking = { id: "bid-1", email: "u@e.com" }; + req.params = { id: "bid-1" }; + bookingService.getBookingById.mockResolvedValue(mockBooking); + + await bookingController.getBookingById(req as Request, res as Response); + + expect(bookingService.getBookingById).toHaveBeenCalledWith("bid-1"); + expect(jsonMock).toHaveBeenCalledWith(mockBooking); + }); + }); + + describe("getBookingsByUserId", () => { + it("returns paginated bookings for user", async () => { + const mockResult = { data: [{ id: "b1" }], total: 1, hasMore: false }; + req.params = { userId: "user-1" }; + req.query = { page: "1", pageSize: "20" }; + bookingService.getBookingsByUserId.mockResolvedValue(mockResult); + + await bookingController.getBookingsByUserId(req as Request, res as Response); + + expect(bookingService.getBookingsByUserId).toHaveBeenCalledWith("user-1", 20, 0); + expect(jsonMock).toHaveBeenCalledWith(mockResult); + }); + }); + + describe("updateBookingStatus", () => { + it("returns 400 when status is invalid", async () => { + req.params = { id: "bid-1" }; + req.body = { status: "invalid" }; + + await bookingController.updateBookingStatus(req as Request, res as Response); + + expect(statusMock).toHaveBeenCalledWith(400); + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ message: "Invalid status" })); + }); + + it("returns 404 when booking not found", async () => { + req.params = { id: "bid-1" }; + req.body = { status: "confirmed" }; + bookingService.updateBookingStatus.mockResolvedValue(null); + + await bookingController.updateBookingStatus(req as Request, res as Response); + + expect(statusMock).toHaveBeenCalledWith(404); + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ message: "Booking not found" })); + }); + + it("returns booking when update succeeds", async () => { + const updated = { id: "bid-1", status: "confirmed" }; + req.params = { id: "bid-1" }; + req.body = { status: "confirmed" }; + bookingService.updateBookingStatus.mockResolvedValue(updated); + + await bookingController.updateBookingStatus(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(updated); + }); + }); +}); diff --git a/server/controllers/__tests__/health.controller.test.ts b/server/controllers/__tests__/health.controller.test.ts new file mode 100644 index 0000000..790c6e4 --- /dev/null +++ b/server/controllers/__tests__/health.controller.test.ts @@ -0,0 +1,88 @@ +/** + * Unit tests for server/controllers/health.controller.ts. + * Covers healthCheck, live, and ready handlers with mocked health-service. + */ +import { Request, Response } from "express"; +import { HealthController } from "../health.controller"; + +jest.mock("../../services/health-service", () => ({ + checkDb: jest.fn(), +})); + +const checkDb = require("../../services/health-service").checkDb; + +describe("HealthController", () => { + let controller: HealthController; + let req: Partial; + let res: Partial; + let jsonMock: jest.Mock; + let statusMock: jest.Mock; + + beforeEach(() => { + controller = new HealthController(); + jsonMock = jest.fn(); + statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + req = {}; + res = { status: statusMock, json: jsonMock }; + jest.clearAllMocks(); + }); + + describe("healthCheck", () => { + it("returns 200 and healthy status when checkDb is true", async () => { + checkDb.mockResolvedValue(true); + await controller.healthCheck(req as Request, res as Response); + expect(statusMock).toHaveBeenCalledWith(200); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + status: "healthy", + checks: { database: "up" }, + }), + }) + ); + }); + + it("returns 503 and unhealthy status when checkDb is false", async () => { + checkDb.mockResolvedValue(false); + await controller.healthCheck(req as Request, res as Response); + expect(statusMock).toHaveBeenCalledWith(503); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + status: "unhealthy", + checks: { database: "down" }, + }), + }) + ); + }); + }); + + describe("live", () => { + it("returns 200 and alive status", async () => { + await controller.live(req as Request, res as Response); + expect(statusMock).toHaveBeenCalledWith(200); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ status: "alive" }), + }) + ); + }); + }); + + describe("ready", () => { + it("delegates to healthCheck", async () => { + checkDb.mockResolvedValue(true); + await controller.ready(req as Request, res as Response); + expect(statusMock).toHaveBeenCalledWith(200); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ status: "healthy" }), + }) + ); + }); + }); +}); diff --git a/server/controllers/admin.controller.ts b/server/controllers/admin.controller.ts index 09dab02..9f178b5 100644 --- a/server/controllers/admin.controller.ts +++ b/server/controllers/admin.controller.ts @@ -1,32 +1,59 @@ -import { Request, Response } from 'express'; -import { contactService } from '../services'; - +import { Request, Response } from "express"; +import { contactService } from "../services"; +import { NotFoundError } from "../../middlewares/errors/error-handler"; export class AdminController { - async getAllContactMessages(req: Request, res: Response) { - try { - const page = req.query.page ? parseInt(req.query.page as string) : 1; - const pageSize = req.query.offset ? parseInt(req.query.pageSize as string) : 20; - const limit = pageSize || 20; // Default to 20 if not provided - const offset = page ? (page - 1) * limit : 0; // - const messages = await contactService.getAllContactMessages(limit, offset); - res.json(messages); - } catch (error) { - res.status(500).json({ error: 'Failed to get contact messages' }); - } + async getAllContactMessages(req: Request, res: Response) { + try { + const page = req.query.page ? parseInt(req.query.page as string) : 1; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; + const limit = pageSize || 20; + const offset = page ? (page - 1) * limit : 0; + const messages = await contactService.getAllContactMessages(limit, offset); + res.json(messages); + } catch (error) { + res.status(500).json({ error: "Failed to get contact messages" }); } + } + async getContactMessageById(req: Request, res: Response) { + try { + const { id } = req.params; + const message = await contactService.getContactMessageById(id); + res.json(message); + } catch (error) { + if (error instanceof NotFoundError) { + return res.status(404).json({ success: false, error: { message: error.message } }); + } + res.status(500).json({ error: "Failed to get contact message" }); + } + } - async markContactMessageAsRead(req: Request, res: Response) { - try { - const { id } = req.params; - await contactService.markContactMessageAsRead(id); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: 'Failed to mark message as read' }); - } + async markContactMessageAsRead(req: Request, res: Response) { + try { + const { id } = req.params; + await contactService.markContactMessageAsRead(id); + res.json({ success: true }); + } catch (error) { + if (error instanceof NotFoundError) { + return res.status(404).json({ success: false, error: { message: error.message } }); + } + res.status(500).json({ error: "Failed to mark message as read" }); } + } + async deleteContactMessage(req: Request, res: Response) { + try { + const { id } = req.params; + await contactService.deleteContactMessage(id); + res.status(204).send(); + } catch (error) { + if (error instanceof NotFoundError) { + return res.status(404).json({ success: false, error: { message: error.message } }); + } + res.status(500).json({ error: "Failed to delete contact message" }); + } + } } export const adminController = new AdminController(); diff --git a/server/controllers/booking.controller.ts b/server/controllers/booking.controller.ts index 7279fab..7f0b3ec 100644 --- a/server/controllers/booking.controller.ts +++ b/server/controllers/booking.controller.ts @@ -1,115 +1,117 @@ -import { Request, Response } from 'express'; -import { bookingService } from '../services'; -import { z } from 'zod'; -import { WebSocketServer } from 'ws'; -import { insertBookingSchema } from '../../config/database/schema/schema-types'; -import { bookingStatus } from '../../config/database/schema/enum'; +import { Request, Response } from "express"; +import { bookingService } from "../services"; +import { z } from "zod"; +import { WebSocketServer } from "ws"; +import { insertBookingSchema } from "../../config/database/schema/schema-types"; +import { bookingStatus } from "../../config/database/schema/enum"; export class BookingController { - async getBookingById(req: Request, res: Response) { - try { - const { id } = req.params; - const booking = await bookingService.getBookingById(id); - res.json(booking); - } catch (error) { - res.status(500).json({ error: 'Failed to get booking' }); - } + async getBookingById(req: Request, res: Response) { + try { + const { id } = req.params; + const booking = await bookingService.getBookingById(id); + res.json(booking); + } catch (error) { + res.status(500).json({ error: "Failed to get booking" }); } - - async getBookingsByUserId(req: Request, res: Response) { - try { - const { userId } = req.params; - const page = req.query.page ? parseInt(req.query.page as string) : 1; - const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; - const limit = pageSize || 20; // Default to 50 if not provided - const offset = page ? (page - 1) * limit : 0; // - const bookings = await bookingService.getBookingsByUserId(userId, limit, offset); - res.json(bookings); - } catch (error) { - res.status(500).json({ error: 'Failed to get user bookings' }); - } + } + + async getBookingsByUserId(req: Request, res: Response) { + try { + const { userId } = req.params; + const page = req.query.page ? parseInt(req.query.page as string) : 1; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; + const limit = pageSize || 20; // Default to 50 if not provided + const offset = page ? (page - 1) * limit : 0; // + const bookings = await bookingService.getBookingsByUserId(userId, limit, offset); + res.json(bookings); + } catch (error) { + res.status(500).json({ error: "Failed to get user bookings" }); } - - async getAllBookings(req: Request, res: Response) { - try { - const status = req.query.status as string; - const page = req.query.page ? parseInt(req.query.page as string) : 1; - const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; - const limit = pageSize || 20; // Default to 50 if not provided - const offset = page ? (page - 1) * limit : 0; // - - if (!req.isAuthenticated()) { - return res.status(401).json({ message: "Authentication required" }); - } - - if (req.user.userType === 'visitor') { - // Visitors can only see their own bookings - const bookings = await bookingService.getBookingsByUserId(req.user.id, limit, offset); - return res.json(bookings); - } else { - // Attendants and admins can see all bookings - const bookings = await bookingService.getAllBookings(status as string, limit, offset); - return res.json(bookings); - } - } catch (error) { - res.status(500).json({ error: 'Failed to get all bookings' }); - } + } + + async getAllBookings(req: Request, res: Response) { + try { + const status = req.query.status as string; + const page = req.query.page ? parseInt(req.query.page as string) : 1; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; + const limit = pageSize || 20; // Default to 50 if not provided + const offset = page ? (page - 1) * limit : 0; // + + if (!req.isAuthenticated()) { + return res.status(401).json({ message: "Authentication required" }); + } + + if (req.user.userType === "visitor") { + // Visitors can only see their own bookings + const bookings = await bookingService.getBookingsByUserId(req.user.id, limit, offset); + return res.json(bookings); + } else { + // Attendants and admins can see all bookings + const bookings = await bookingService.getAllBookings(status as string, limit, offset); + return res.json(bookings); + } + } catch (error) { + res.status(500).json({ error: "Failed to get all bookings" }); } + } - async createBooking(req: Request, res: Response) { - return async (wss: WebSocketServer) => { - try { - const validatedData = insertBookingSchema.parse(req.body); - - // Add userId if available (user is logged in) - if (req.isAuthenticated()) { - validatedData.userId = req.user.id; - } - - const booking = await bookingService.createBooking(validatedData); - - // Send booking confirmation via WebSocket - wss.clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ - type: 'booking_confirmation', - data: booking - })); - } - }); - - res.status(201).json(booking); - } catch (error) { - if (error instanceof z.ZodError) { - return res.status(400).json({ errors: error.errors }); - } - console.error("Error creating booking:", error); - res.status(500).json({ message: "Failed to create booking" }); - } - } - } - - async updateBookingStatus(req: Request, res: Response) { - try { - const { id } = req.params; - const { status } = req.body; - const bookingsStatus = bookingStatus.enumValues; - - if (!status || !bookingsStatus.includes(status)) { - return res.status(400).json({ message: "Invalid status" }); - } + async createBooking(req: Request, res: Response) { + return async (wss: WebSocketServer) => { + try { + const validatedData = insertBookingSchema.parse(req.body); - const booking = await bookingService.updateBookingStatus(id, status); - - if (!booking) { - return res.status(404).json({ message: "Booking not found" }); - } + // Add userId if available (user is logged in) + if (req.isAuthenticated()) { + validatedData.userId = req.user.id; + } - res.json(booking); - } catch (error) { - res.status(500).json({ error: 'Failed to update booking status' }); + const booking = await bookingService.createBooking(validatedData); + + // Send booking confirmation via WebSocket + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send( + JSON.stringify({ + type: "booking_confirmation", + data: booking, + }) + ); + } + }); + + res.status(201).json(booking); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ errors: error.errors }); } + console.error("Error creating booking:", error); + res.status(500).json({ message: "Failed to create booking" }); + } + }; + } + + async updateBookingStatus(req: Request, res: Response) { + try { + const { id } = req.params; + const { status } = req.body; + const bookingsStatus = bookingStatus.enumValues; + + if (!status || !bookingsStatus.includes(status)) { + return res.status(400).json({ message: "Invalid status" }); + } + + const booking = await bookingService.updateBookingStatus(id, status); + + if (!booking) { + return res.status(404).json({ message: "Booking not found" }); + } + + res.json(booking); + } catch (error) { + res.status(500).json({ error: "Failed to update booking status" }); } + } } export const bookingController = new BookingController(); diff --git a/server/controllers/contact.controller.ts b/server/controllers/contact.controller.ts index ce5a764..3ebd36c 100644 --- a/server/controllers/contact.controller.ts +++ b/server/controllers/contact.controller.ts @@ -1,56 +1,56 @@ -import { Request, Response } from 'express'; -import { ContactMessage, contactService } from '../services'; -import { insertContactMessageSchema } from '../../config/database/schema/schema-types'; -import { z } from 'zod'; -import { forumController } from '../controllers'; +import { Request, Response } from "express"; +import { ContactMessage, contactService } from "../services"; +import { insertContactMessageSchema } from "../../config/database/schema/schema-types"; +import { z } from "zod"; +import { forumController } from "../controllers"; export class ContactController { - async getAllContactMessages(req: Request, res: Response) { - try { - const page = req.query.page ? parseInt(req.query.page as string) : 1; - const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; - const limit = pageSize || 20; // Default to 50 if not provided - const offset = page ? (page - 1) * limit : 0; // + async getAllContactMessages(req: Request, res: Response) { + try { + const page = req.query.page ? parseInt(req.query.page as string) : 1; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; + const limit = pageSize || 20; // Default to 50 if not provided + const offset = page ? (page - 1) * limit : 0; // - const messages = await contactService.getAllContactMessages(limit, offset); - res.json(messages); - } catch (error) { - res.status(500).json({ error: 'Failed to get contact messages' }); - } + const messages = await contactService.getAllContactMessages(limit, offset); + res.json(messages); + } catch (error) { + res.status(500).json({ error: "Failed to get contact messages" }); } + } - async createContactMessage(req: Request, res: Response) { - try { - const validatedData: Partial = insertContactMessageSchema.parse(req.body); - const message = await contactService.createContactMessage(validatedData); - res.status(201).json({ success: true, id: message.id }); - } catch (error) { - if (error instanceof z.ZodError) { - return res.status(400).json({ errors: error.errors }); - } - console.error("Error submitting contact form:", error); - res.status(500).json({ message: "Failed to submit contact form" }); - } + async createContactMessage(req: Request, res: Response) { + try { + const validatedData: Partial = insertContactMessageSchema.parse(req.body); + const message = await contactService.createContactMessage(validatedData); + res.status(201).json({ success: true, id: message.id }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ errors: error.errors }); + } + console.error("Error submitting contact form:", error); + res.status(500).json({ message: "Failed to submit contact form" }); } + } - async markContactMessageAsRead(req: Request, res: Response) { - try { - const { id } = req.params; - await contactService.markContactMessageAsRead(id); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: 'Failed to mark message as read' }); - } + async markContactMessageAsRead(req: Request, res: Response) { + try { + const { id } = req.params; + await contactService.markContactMessageAsRead(id); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: "Failed to mark message as read" }); } + } - async getUnreadContactMessagesCount(req: Request, res: Response) { - try { - const count = await contactService.getUnreadContactMessagesCount(); - res.json({ count }); - } catch (error) { - res.status(500).json({ error: 'Failed to get unread messages count' }); - } + async getUnreadContactMessagesCount(req: Request, res: Response) { + try { + const count = await contactService.getUnreadContactMessagesCount(); + res.json({ count }); + } catch (error) { + res.status(500).json({ error: "Failed to get unread messages count" }); } + } } export const contactController = new ContactController(); diff --git a/server/controllers/forum.controller.ts b/server/controllers/forum.controller.ts index ceaca72..9214464 100644 --- a/server/controllers/forum.controller.ts +++ b/server/controllers/forum.controller.ts @@ -1,180 +1,192 @@ -import { Request, Response } from 'express'; -import { forumService } from '../services'; +import { Request, Response } from "express"; +import { forumService } from "../services"; import WebSocket, { WebSocketServer } from "ws"; import { insertPostSchema, insertCommentSchema } from "../../config/database/schema/schema-types"; import { z } from "zod"; - export class ForumController { - async getPostById(req: Request, res: Response) { - try { - const { id } = req.params; - const post = await forumService.getPostById(id); - if (!post) { - return res.status(404).json({ message: "Post not found" }); - } - - // Check if user can access attendant-only post - if (post.isAttendantOnly && - (!req.isAuthenticated() || - (req.user.userType !== 'attendant' && req.user.userType !== 'admin'))) { - return res.status(403).json({ message: "Access denied" }); - } - - - res.json(post); - } catch (error) { - res.status(500).json({ error: 'Failed to get post' }); + async getPostById(req: Request, res: Response) { + try { + const { id } = req.params; + const post = await forumService.getPostById(id); + if (!post) { + return res.status(404).json({ message: "Post not found" }); + } + + // Check if user can access attendant-only post + if ( + post.isAttendantOnly && + (!req.isAuthenticated() || + (req.user.userType !== "attendant" && req.user.userType !== "admin")) + ) { + return res.status(403).json({ message: "Access denied" }); + } + + res.json(post); + } catch (error) { + res.status(500).json({ error: "Failed to get post" }); + } + } + + async getAllPosts(req: Request, res: Response) { + try { + const { attendantOnly } = req.query; + const page = req.query.page ? parseInt(req.query.page as string) : undefined; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : undefined; + const limit = pageSize || 20; // Default to 50 if not provided + const offset = page ? (page - 1) * limit : 0; // + + let isAttendantOnly: boolean | undefined; + + if (attendantOnly === "true") { + // Check if user can access attendant-only posts + if ( + !req.isAuthenticated() || + (req.user.userType !== "attendant" && req.user.userType !== "admin") + ) { + return res.status(403).json({ message: "Access denied" }); } + isAttendantOnly = true; + } else if (attendantOnly === "false") { + isAttendantOnly = false; + } + + const posts = await forumService.getAllPosts(isAttendantOnly, limit, offset); + res.json(posts); + } catch (error) { + console.error("Error fetching posts:", error); + res.status(500).json({ message: "Failed to fetch posts" }); } + } - async getAllPosts(req: Request, res: Response) { - try { - const { attendantOnly } = req.query; - const page = req.query.page ? parseInt(req.query.page as string) : undefined; - const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : undefined; - const limit = pageSize || 20; // Default to 50 if not provided - const offset = page ? (page - 1) * limit : 0; // - - let isAttendantOnly: boolean | undefined; - - if (attendantOnly === 'true') { - // Check if user can access attendant-only posts - if (!req.isAuthenticated() || - (req.user.userType !== 'attendant' && req.user.userType !== 'admin')) { - return res.status(403).json({ message: "Access denied" }); - } - isAttendantOnly = true; - } else if (attendantOnly === 'false') { - isAttendantOnly = false; - } - - const posts = await forumService.getAllPosts(isAttendantOnly, limit, offset); - res.json(posts); - } catch (error) { - console.error("Error fetching posts:", error); - res.status(500).json({ message: "Failed to fetch posts" }); + async createPost(req: Request, res: Response) { + return async (wss: WebSocketServer) => { + try { + if (!req.isAuthenticated()) { + return res.status(401).json({ message: "Authentication required" }); } - } - async createPost(req: Request, res: Response) { - return async (wss: WebSocketServer) => { - try { - if (!req.isAuthenticated()) { - return res.status(401).json({ message: "Authentication required" }); - } - - const validatedData = insertPostSchema.parse(req.body); - - // Check if user can create attendant-only posts - if (validatedData.isAttendantOnly && - req.user.userType !== 'attendant' && - req.user.userType !== 'admin') { - return res.status(403).json({ message: "Access denied: must be one of attendant or admin" }); - } - - // Set the user ID from the authenticated user - validatedData.authorId = req.user.id; - - const now = new Date(); - const post = await forumService.createPost({ - ...validatedData, - isAttendantOnly: validatedData.isAttendantOnly ?? false, - createdAt: now, - updatedAt: now, - deletedAt: validatedData.deletedAt ?? null - }); - - // Notify via WebSocket - wss.clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ - type: 'new_post', - data: post - })); - } - }); - - res.status(201).json(post); - } catch (error) { - if (error instanceof z.ZodError) { - return res.status(400).json({ errors: error.errors }); - } - console.error("Error creating post:", error); - res.status(500).json({ message: "Failed to create post" }); - } + const validatedData = insertPostSchema.parse(req.body); + + // Check if user can create attendant-only posts + if ( + validatedData.isAttendantOnly && + req.user.userType !== "attendant" && + req.user.userType !== "admin" + ) { + return res + .status(403) + .json({ message: "Access denied: must be one of attendant or admin" }); } - } - async getCommentsByPostId(req: Request, res: Response) { - try { - const { postId } = req.params; - const page = req.query.page ? parseInt(req.query.page as string) : 1; - const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; - const limit = pageSize || 20; // Default to 50 if not provided - const offset = page ? (page - 1) * limit : 0; // - - const comments = await forumService.getCommentsByPostId(postId, limit, offset); - res.json(comments); - } catch (error) { - res.status(500).json({ error: 'Failed to get comments' }); + // Set the user ID from the authenticated user + validatedData.authorId = req.user.id; + + const now = new Date(); + const post = await forumService.createPost({ + ...validatedData, + isAttendantOnly: validatedData.isAttendantOnly ?? false, + createdAt: now, + updatedAt: now, + deletedAt: validatedData.deletedAt ?? null, + }); + + // Notify via WebSocket + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send( + JSON.stringify({ + type: "new_post", + data: post, + }) + ); + } + }); + + res.status(201).json(post); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ errors: error.errors }); } + console.error("Error creating post:", error); + res.status(500).json({ message: "Failed to create post" }); + } + }; + } + + async getCommentsByPostId(req: Request, res: Response) { + try { + const { postId } = req.params; + const page = req.query.page ? parseInt(req.query.page as string) : 1; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; + const limit = pageSize || 20; // Default to 50 if not provided + const offset = page ? (page - 1) * limit : 0; // + + const comments = await forumService.getCommentsByPostId(postId, limit, offset); + res.json(comments); + } catch (error) { + res.status(500).json({ error: "Failed to get comments" }); } + } - async createComment(req: Request, res: Response) { - return async (wss: WebSocketServer) => { - try { - if (!req.isAuthenticated()) { - return res.status(401).json({ message: "Authentication required" }); - } - - const { id } = req.params; - const post = await forumService.getPostById(id); - - if (!post) { - return res.status(404).json({ message: "Post not found" }); - } - - // Check if user can comment on attendant-only post - if (post.isAttendantOnly && - req.user.userType !== 'attendant' && - req.user.userType !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const validatedData = insertCommentSchema.parse({ - ...req.body, - postId: id, - authorId: req.user.id, - createdAt: new Date(), - updatedAt: new Date() - }); - - const comment = await forumService.createComment(validatedData); - - // Notify via WebSocket - wss.clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ - type: 'new_comment', - data: { - ...comment, - postId: id - } - })); - } - }); - - res.status(201).json(comment); - } catch (error) { - if (error instanceof z.ZodError) { - return res.status(400).json({ errors: error.errors }); - } - console.error("Error creating comment:", error); - res.status(500).json({ message: "Failed to create comment" }); - } + async createComment(req: Request, res: Response) { + return async (wss: WebSocketServer) => { + try { + if (!req.isAuthenticated()) { + return res.status(401).json({ message: "Authentication required" }); } - } + + const { id } = req.params; + const post = await forumService.getPostById(id); + + if (!post) { + return res.status(404).json({ message: "Post not found" }); + } + + // Check if user can comment on attendant-only post + if ( + post.isAttendantOnly && + req.user.userType !== "attendant" && + req.user.userType !== "admin" + ) { + return res.status(403).json({ message: "Access denied" }); + } + + const validatedData = insertCommentSchema.parse({ + ...req.body, + postId: id, + authorId: req.user.id, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const comment = await forumService.createComment(validatedData); + + // Notify via WebSocket + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send( + JSON.stringify({ + type: "new_comment", + data: { + ...comment, + postId: id, + }, + }) + ); + } + }); + + res.status(201).json(comment); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ errors: error.errors }); + } + console.error("Error creating comment:", error); + res.status(500).json({ message: "Failed to create comment" }); + } + }; + } } export const forumController = new ForumController(); diff --git a/server/controllers/gallery.controller.ts b/server/controllers/gallery.controller.ts index 26405c5..e53e4de 100644 --- a/server/controllers/gallery.controller.ts +++ b/server/controllers/gallery.controller.ts @@ -1,74 +1,73 @@ -import { Request, Response } from 'express'; -import { galleryService } from '../services'; -import { uploadSingleImage, getImageUrl } from '../../config/bucket-storage/uploadMiddleware'; +import { Request, Response } from "express"; +import { galleryService } from "../services"; +import { uploadSingleImage, getImageUrl } from "../../config/bucket-storage/uploadMiddleware"; export class GalleryController { - async getGalleryItemById(req: Request, res: Response) { - try { - const { id } = req.params; - const item = await galleryService.getGalleryItemById(id); - res.json(item); - } catch (error) { - res.status(500).json({ error: 'Failed to get gallery item' }); - } + async getGalleryItemById(req: Request, res: Response) { + try { + const { id } = req.params; + const item = await galleryService.getGalleryItemById(id); + res.json(item); + } catch (error) { + res.status(500).json({ error: "Failed to get gallery item" }); } + } - async getAllGalleryItems(req: Request, res: Response) { - try { - const category = req.query.category as string; - const page = req.query.page ? parseInt(req.query.page as string) : 1; - const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; - const limit = pageSize || 20; // Default to 50 if not provided - const offset = page ? (page - 1) * limit : 0; // - + async getAllGalleryItems(req: Request, res: Response) { + try { + const category = req.query.category as string; + const page = req.query.page ? parseInt(req.query.page as string) : 1; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 20; + const limit = pageSize || 20; // Default to 50 if not provided + const offset = page ? (page - 1) * limit : 0; // - const items = await galleryService.getAllGalleryItems(category, limit, offset); + const items = await galleryService.getAllGalleryItems(category, limit, offset); - res.json(items); - } catch (error) { - res.status(500).json({ error: 'Failed to get gallery items' }); - } + res.json(items); + } catch (error) { + res.status(500).json({ error: "Failed to get gallery items" }); } + } - async getGalleryItemsByCategory(req: Request, res: Response) { - try { - const { category } = req.params; - const page = req.query.page ? parseInt(req.query.page as string) : undefined; - const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : undefined; - const limit = pageSize || 20; // Default to 50 if not provided - const offset = page ? (page - 1) * limit : 0; // + async getGalleryItemsByCategory(req: Request, res: Response) { + try { + const { category } = req.params; + const page = req.query.page ? parseInt(req.query.page as string) : undefined; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : undefined; + const limit = pageSize || 20; // Default to 50 if not provided + const offset = page ? (page - 1) * limit : 0; // - const items = await galleryService.getGalleryItemsByCategory(category, limit, offset); - res.json(items); - } catch (error) { - res.status(500).json({ error: 'Failed to get gallery items by category' }); - } + const items = await galleryService.getGalleryItemsByCategory(category, limit, offset); + res.json(items); + } catch (error) { + res.status(500).json({ error: "Failed to get gallery items by category" }); } + } - async createGalleryItem(req: Request, res: Response) { - try { - // Handle file upload first - uploadSingleImage(req, res, async (err) => { - if (err) { - return res.status(400).json({ error: err.message }); - } + async createGalleryItem(req: Request, res: Response) { + try { + // Handle file upload first + uploadSingleImage(req, res, async (err) => { + if (err) { + return res.status(400).json({ error: err.message }); + } - if (!req.file) { - return res.status(400).json({ error: 'Image file is required' }); - } + if (!req.file) { + return res.status(400).json({ error: "Image file is required" }); + } - const data = { - ...req.body, - imageUrl: getImageUrl(req.file) // Get Cloudinary URL - }; + const data = { + ...req.body, + imageUrl: getImageUrl(req.file), // Get Cloudinary URL + }; - const item = await galleryService.createGalleryItem(data); - res.status(201).json(item); - }); - } catch (error) { - res.status(500).json({ error: 'Failed to create gallery item' }); - } + const item = await galleryService.createGalleryItem(data); + res.status(201).json(item); + }); + } catch (error) { + res.status(500).json({ error: "Failed to create gallery item" }); } + } } export const galleryController = new GalleryController(); diff --git a/server/controllers/health.controller.ts b/server/controllers/health.controller.ts index 43d2ef4..c2a8cf2 100644 --- a/server/controllers/health.controller.ts +++ b/server/controllers/health.controller.ts @@ -1,23 +1,49 @@ -import { Request, Response } from 'express'; -import { userService } from '../services'; +/** + * Health check controller — exposes a single GET /health endpoint. + * Returns a standard REST envelope with status, version, and dependency checks (e.g. DB). + * Used by load balancers and monitoring; supports CORS for dashboard UIs. + */ +import { Request, Response } from "express"; +import { checkDb } from "../services/health-service"; +import type { ApiSuccessResponse, HealthCheckData } from "../types/api-response"; + +const version = process.env.npm_package_version ?? "1.0.0"; export class HealthController { - async healthCheck(req: Request, res: Response) { - console.log("\n\n hit health controller", req.url) - try { - const isHealthy = await userService.healthCheck(); - res.json({ - status: isHealthy ? 'healthy' : 'unhealthy', - timestamp: new Date().toISOString() - }); - } catch (error) { - res.status(500).json({ - status: 'unhealthy', - error: 'Health check failed', - timestamp: new Date().toISOString() - }); - } - } + /** + * GET /health — returns service health and dependency status (readiness). + * 200 when healthy, 503 when unhealthy (e.g. DB down). + */ + async healthCheck(_req: Request, res: Response): Promise { + const dbOk = await checkDb(); + const status: HealthCheckData["status"] = dbOk ? "healthy" : "unhealthy"; + const data: HealthCheckData = { + status, + timestamp: new Date().toISOString(), + version, + checks: { database: dbOk ? "up" : "down" }, + }; + const body: ApiSuccessResponse = { success: true, data }; + res.status(dbOk ? 200 : 503).json(body); + } + + /** + * GET /live — liveness probe (no dependencies). Always 200 if process is running. + * Use for Kubernetes liveness; do not use for readiness. + */ + async live(_req: Request, res: Response): Promise { + res.status(200).json({ + success: true, + data: { status: "alive", timestamp: new Date().toISOString() }, + }); + } + + /** + * GET /ready — readiness probe (same as /health). 200 when DB is up and ready for traffic. + */ + async ready(req: Request, res: Response): Promise { + return this.healthCheck(req, res); + } } export const healthController = new HealthController(); diff --git a/server/controllers/history.controller.ts b/server/controllers/history.controller.ts index 4be241a..10934a1 100644 --- a/server/controllers/history.controller.ts +++ b/server/controllers/history.controller.ts @@ -1,76 +1,75 @@ -import { Request, Response } from 'express'; -import { historyService } from '../services'; +import { Request, Response } from "express"; +import { historyService } from "../services"; export class HistoryController { - async getHistoryContentById(req: Request, res: Response) { - try { - const { id } = req.params; - console.log("\n\n 💥💥💥💥💥getHistoryContentById id: ", id); + async getHistoryContentById(req: Request, res: Response) { + try { + const { id } = req.params; + console.log("\n\n 💥💥💥💥💥getHistoryContentById id: ", id); - const content = await historyService.getHistoryContentById(id); - res.json(content); - } catch (error) { - res.status(500).json({ error: 'Failed to get history content' }); - } + const content = await historyService.getHistoryContentById(id); + res.json(content); + } catch (error) { + res.status(500).json({ error: "Failed to get history content" }); } + } - async getHistoryContentBySlug(req: Request, res: Response) { - try { - const { slug } = req.params; - const content = await historyService.getHistoryContentBySlug(slug); - res.json(content); - } catch (error) { - res.status(500).json({ error: 'Failed to get history content by slug' }); - } + async getHistoryContentBySlug(req: Request, res: Response) { + try { + const { slug } = req.params; + const content = await historyService.getHistoryContentBySlug(slug); + res.json(content); + } catch (error) { + res.status(500).json({ error: "Failed to get history content by slug" }); } + } - async getAllHistoryContent(req: Request, res: Response) { - try { - const page = req.query?.page ? Number(req.query?.page as string) : 1; - const pageSize = req.query?.pageSize ? Number(req.query.pageSize as string) : 20; - const limit = pageSize; // Default to 50 if not provided - const offset = page ? (page - 1) * limit : 0; // + async getAllHistoryContent(req: Request, res: Response) { + try { + const page = req.query?.page ? Number(req.query?.page as string) : 1; + const pageSize = req.query?.pageSize ? Number(req.query.pageSize as string) : 20; + const limit = pageSize; // Default to 50 if not provided + const offset = page ? (page - 1) * limit : 0; // - const content = await historyService.getAllHistoryContent(limit, offset); + const content = await historyService.getAllHistoryContent(limit, offset); - res.json(content); - } catch (error) { - res.status(500).json({ error: 'Failed to get all history content' }); - } + res.json(content); + } catch (error) { + res.status(500).json({ error: "Failed to get all history content" }); } + } - async createHistoryContent(req: Request, res: Response) { - try { - const data = req.body; - const content = await historyService.createHistoryContent(data); - res.status(201).json(content); - } catch (error) { - res.status(500).json({ error: 'Failed to create history content' }); - } + async createHistoryContent(req: Request, res: Response) { + try { + const data = req.body; + const content = await historyService.createHistoryContent(data); + res.status(201).json(content); + } catch (error) { + res.status(500).json({ error: "Failed to create history content" }); } + } - async updateHistoryContent(req: Request, res: Response) { - try { - const { id } = req.params; - const data = req.body; - const content = await historyService.updateHistoryContent(id, data); - res.json(content); - } catch (error) { - res.status(500).json({ error: 'Failed to update history content' }); - } + async updateHistoryContent(req: Request, res: Response) { + try { + const { id } = req.params; + const data = req.body; + const content = await historyService.updateHistoryContent(id, data); + res.json(content); + } catch (error) { + res.status(500).json({ error: "Failed to update history content" }); } + } - // delete an history - async deleteHistoryContent(req: Request, res: Response) { - try { - const { id } = req.params; - await historyService.deleteHistoryContent(id); - res.json({ message: 'History content deleted successfully' }); - } catch (error) { - res.status(500).json({ error: 'Failed to delete history content' }); - } + // delete an history + async deleteHistoryContent(req: Request, res: Response) { + try { + const { id } = req.params; + await historyService.deleteHistoryContent(id); + res.json({ message: "History content deleted successfully" }); + } catch (error) { + res.status(500).json({ error: "Failed to delete history content" }); } + } } - export const historyController = new HistoryController(); diff --git a/server/controllers/index.ts b/server/controllers/index.ts index 584ab75..e5a9a1a 100644 --- a/server/controllers/index.ts +++ b/server/controllers/index.ts @@ -1,12 +1,12 @@ -export { userController } from './user.controller'; -export { historyController } from './history.controller'; -export { galleryController } from './gallery.controller'; -export { bookingController } from './booking.controller'; -export { forumController } from './forum.controller'; -export { contactController } from './contact.controller'; -export { healthController } from './health.controller'; -export { adminController } from './admin.controller'; -export { postLikesController } from './post_like.controller'; +export { userController } from "./user.controller"; +export { historyController } from "./history.controller"; +export { galleryController } from "./gallery.controller"; +export { bookingController } from "./booking.controller"; +export { forumController } from "./forum.controller"; +export { contactController } from "./contact.controller"; +export { healthController } from "./health.controller"; +export { adminController } from "./admin.controller"; +export { postLikesController } from "./post_like.controller"; // Legacy DatabaseStorage class for backward compatibility // export { DatabaseStorage, drizzleService } from '../services'; diff --git a/server/controllers/post_like.controller.ts b/server/controllers/post_like.controller.ts index 45268b2..e53ab51 100644 --- a/server/controllers/post_like.controller.ts +++ b/server/controllers/post_like.controller.ts @@ -1,45 +1,40 @@ -import { Request, Response } from 'express'; -import { postLikeService } from '../services'; +import { Request, Response } from "express"; +import { postLikeService } from "../services"; export class PostLikesController { - async likePost(req: Request, res: Response) { - try { - const data = req.body; - - if (!data.userId || !data.postId) - return res.status(400).send({ - error: true, - message: "Missing required fields post id and author id" - }); - - - const content = await postLikeService.postLike(data); - res.json(content); - } catch (error) { - res.status(500).json({ error: 'Failed like a post with id: ' + req.body.postId }); - } + async likePost(req: Request, res: Response) { + try { + const data = req.body; + + if (!data.userId || !data.postId) + return res.status(400).send({ + error: true, + message: "Missing required fields post id and author id", + }); + + const content = await postLikeService.postLike(data); + res.json(content); + } catch (error) { + res.status(500).json({ error: "Failed like a post with id: " + req.body.postId }); } - - async unlikePost(req: Request, res: Response) { - const data = req.body; - - if (!data.userId || !data.postId) - return res.status(400).send({ - error: true, - message: "Missing required fields post id and author id" - }); - - - try { - const content = await postLikeService.unlikePost(data); - res.json(content); - } catch (error) { - res.status(500).json({ error: 'Failed to unlike a post with id: ' + req.body.postId }); - } + } + + async unlikePost(req: Request, res: Response) { + const data = req.body; + + if (!data.userId || !data.postId) + return res.status(400).send({ + error: true, + message: "Missing required fields post id and author id", + }); + + try { + const content = await postLikeService.unlikePost(data); + res.json(content); + } catch (error) { + res.status(500).json({ error: "Failed to unlike a post with id: " + req.body.postId }); } - - + } } - export const postLikesController = new PostLikesController(); diff --git a/server/controllers/user.controller.ts b/server/controllers/user.controller.ts index 8122777..349fbdb 100644 --- a/server/controllers/user.controller.ts +++ b/server/controllers/user.controller.ts @@ -1,107 +1,108 @@ -import { Request, Response } from 'express'; -import { userService } from '../services'; +import { Request, Response } from "express"; +import { userService } from "../services"; export class UserController { - async getUser(req: Request, res: Response) { - try { - const { id } = req.params; - const user = await userService.getUser(id); - res.json(user); - } catch (error) { - res.status(500).json({ error: 'Failed to get user' }); - } + async getUser(req: Request, res: Response) { + try { + const { id } = req.params; + const user = await userService.getUser(id); + res.json(user); + } catch (error) { + res.status(500).json({ error: "Failed to get user" }); } + } - async getAllUsers(req: Request, res: Response) { - console.log("\n\n hit user profile route for get all users"); + async getAllUsers(req: Request, res: Response) { + console.log("\n\n hit user profile route for get all users"); - // Handle role parameter - let role = req.query.role as "visitor" | "attendant" | "admin" | undefined; - if (role && !['visitor', 'attendant', 'admin'].includes(role)) { - role = undefined; - console.warn("\n\n Invalid role provided, resetting to undefined"); - } + // Handle role parameter + let role = req.query.role as "visitor" | "attendant" | "admin" | undefined; + if (role && !["visitor", "attendant", "admin"].includes(role)) { + role = undefined; + console.warn("\n\n Invalid role provided, resetting to undefined"); + } - // Fix pagination parameters - const page = req.query.page ? Math.max(1, parseInt(req.query.page as string)) : 1; // Default to 1, minimum 1 - const pageSize = req.query.pageSize ? Math.min(100, Math.max(1, parseInt(req.query.pageSize as string))) : 20; // Default 20, clamp 1-100 + // Fix pagination parameters + const page = req.query.page ? Math.max(1, parseInt(req.query.page as string)) : 1; // Default to 1, minimum 1 + const pageSize = req.query.pageSize + ? Math.min(100, Math.max(1, parseInt(req.query.pageSize as string))) + : 20; // Default 20, clamp 1-100 - const limit = pageSize; - const offset = (page - 1) * limit; + const limit = pageSize; + const offset = (page - 1) * limit; - try { - const users = await userService.getAllUsers(limit, offset, role); - res.json(users); - } catch (error) { - console.error('Error in getAllUsers controller:', error); - res.status(500).json({ error: 'Failed to get all users' }); - } + try { + const users = await userService.getAllUsers(limit, offset, role); + res.json(users); + } catch (error) { + console.error("Error in getAllUsers controller:", error); + res.status(500).json({ error: "Failed to get all users" }); } + } - async getUserByUsername(req: Request, res: Response) { - try { - const { username } = req.params; - const user = await userService.getUserByUsername(username); - res.json(user); - } catch (error) { - res.status(500).json({ error: 'Failed to get user by username' }); - } + async getUserByUsername(req: Request, res: Response) { + try { + const { username } = req.params; + const user = await userService.getUserByUsername(username); + res.json(user); + } catch (error) { + res.status(500).json({ error: "Failed to get user by username" }); } + } - async getUserByEmail(req: Request, res: Response) { - try { - const { email } = req.query; - const user = await userService.getUserByEmail(email as string); - res.json(user); - } catch (error) { - res.status(500).json({ error: 'Failed to get user by email' }); - } + async getUserByEmail(req: Request, res: Response) { + try { + const { email } = req.query; + const user = await userService.getUserByEmail(email as string); + res.json(user); + } catch (error) { + res.status(500).json({ error: "Failed to get user by email" }); } + } - async createUser(req: Request, res: Response) { - try { - const userData = req.body; - const user = await userService.createUser(userData); - res.status(201).json(user); - } catch (error) { - res.status(500).json({ error: 'Failed to create user' }); - } + async createUser(req: Request, res: Response) { + try { + const userData = req.body; + const user = await userService.createUser(userData); + res.status(201).json(user); + } catch (error) { + res.status(500).json({ error: "Failed to create user" }); } + } - async updateUser(req: Request, res: Response) { - try { - const { id } = req.params; - const userData = req.body; - const user = await userService.updateUser(id, userData); - res.json(user); - } catch (error) { - res.status(500).json({ error: 'Failed to update user' }); - } + async updateUser(req: Request, res: Response) { + try { + const { id } = req.params; + const userData = req.body; + const user = await userService.updateUser(id, userData); + res.json(user); + } catch (error) { + res.status(500).json({ error: "Failed to update user" }); } + } - // delete user by email - async deleteUserByEmail(req: Request, res: Response) { - try { - const { email } = req.params; - await userService.deleteUserByEmail(email); - res.sendStatus(204).send({ message: 'User deleted successfully' }); - } catch (error) { - res.status(500).json({ error: 'Failed to delete user' }); - } + // delete user by email + async deleteUserByEmail(req: Request, res: Response) { + try { + const { email } = req.params; + await userService.deleteUserByEmail(email); + res.sendStatus(204).send({ message: "User deleted successfully" }); + } catch (error) { + res.status(500).json({ error: "Failed to delete user" }); } + } - // delete user by id - async deleteUserById(req: Request, res: Response) { - try { - const { id } = req.params; - await userService.deleteUserById(id); - res.sendStatus(204).send({ message: 'User deleted successfully' }); - } catch (error) { - console.error('\n\n 💥💥💥💥💥💥💥💥💥 rror deleting user:', error); - res.status(500).json({ error: 'Failed to delete user' }); - } - + // delete user by id + async deleteUserById(req: Request, res: Response) { + try { + const { id } = req.params; + await userService.deleteUserById(id); + res.sendStatus(204).send({ message: "User deleted successfully" }); + } catch (error) { + console.error("\n\n 💥💥💥💥💥💥💥💥💥 rror deleting user:", error); + res.status(500).json({ error: "Failed to delete user" }); } + } } export const userController = new UserController(); diff --git a/server/routes/__tests__/bookings-route.test.ts b/server/routes/__tests__/bookings-route.test.ts new file mode 100644 index 0000000..440ccab --- /dev/null +++ b/server/routes/__tests__/bookings-route.test.ts @@ -0,0 +1,36 @@ +/** + * Unit tests for server/routes/bookings-route.ts. + * Verifies that booking routes are defined and delegate to bookingController. + * Does not start HTTP server; uses controller mocks where needed. + */ +import { Router } from "express"; +import bookingRoutesFactory from "../bookings-route"; + +jest.mock("ws", () => ({ + WebSocketServer: jest.fn().mockImplementation(() => ({ + clients: { forEach: jest.fn() }, + })), +})); + +describe("Bookings route", () => { + it("returns a Router when given a WebSocketServer", () => { + const { WebSocketServer } = require("ws"); + const wss = new WebSocketServer(); + const router = bookingRoutesFactory(wss); + expect(router).toBeDefined(); + expect(typeof router).toBe("function"); + expect(router.stack).toBeDefined(); + }); + + it("registers GET /bookings and GET /bookings/:id", () => { + const { WebSocketServer } = require("ws"); + const wss = new WebSocketServer(); + const router = bookingRoutesFactory(wss) as Router; + const layers = router.stack.filter( + (r: { route?: unknown }) => r.route + ) as unknown as Array<{ route: { path: string } }>; + const paths = layers.map((r) => r.route.path); + expect(paths).toContain("/bookings"); + expect(paths).toContain("/bookings/:id"); + }); +}); diff --git a/server/routes/__tests__/cors-integration.test.ts b/server/routes/__tests__/cors-integration.test.ts new file mode 100644 index 0000000..8440778 --- /dev/null +++ b/server/routes/__tests__/cors-integration.test.ts @@ -0,0 +1,78 @@ +/** + * CORS integration tests — assert CORS behavior on the real Express app. + * Uses supertest against createApp() with health route only; health service is mocked. + * Ensures OPTIONS preflight and GET with Origin return correct CORS headers. + */ +/// +import request from "supertest"; +import { createApp } from "../../../app"; +import healthRoute from "../health-check-route"; +import errorHandler from "../../../middlewares/errors/error-handler"; + +jest.mock("../../services/health-service", () => ({ + checkDb: jest.fn().mockResolvedValue(true), +})); + +const allowedOrigin = process.env.FRONTEND_URL || "http://localhost:3000"; +const basePath = "/api/v1"; + +function buildTestApp(): ReturnType { + const app = createApp(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test context Express typings may not expose .use() + (app as any).use(basePath, healthRoute); + (app as any).use(errorHandler); + return app; +} + +describe("CORS integration", () => { + const app = buildTestApp(); + + describe("OPTIONS preflight", () => { + it("returns 204 for OPTIONS with allowed Origin and sets CORS headers", async () => { + const res = await request(app) + .options(`${basePath}/health`) + .set("Origin", allowedOrigin) + .set("Access-Control-Request-Method", "GET"); + + expect(res.status).toBe(204); + expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); + + it("allows OPTIONS without Origin (e.g. same-origin)", async () => { + const res = await request(app).options(`${basePath}/health`); + expect(res.status).toBe(204); + }); + }); + + describe("GET with Origin", () => { + it("returns 200 and CORS headers for allowed Origin", async () => { + const res = await request(app).get(`${basePath}/health`).set("Origin", allowedOrigin); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); + expect(res.body).toMatchObject({ success: true, data: { status: "healthy" } }); + }); + + it("does not set Access-Control-Allow-Origin for disallowed Origin", async () => { + const res = await request(app) + .get(`${basePath}/health`) + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(500); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + }); + + describe("exposed headers", () => { + it("response includes X-Request-ID when present in request", async () => { + const res = await request(app) + .get(`${basePath}/health`) + .set("Origin", allowedOrigin) + .set("X-Request-ID", "test-id-123"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-expose-headers"]).toMatch(/x-request-id/i); + }); + }); +}); diff --git a/server/routes/__tests__/cors.test.ts b/server/routes/__tests__/cors.test.ts new file mode 100644 index 0000000..19ee9f5 --- /dev/null +++ b/server/routes/__tests__/cors.test.ts @@ -0,0 +1,76 @@ +/** + * CORS config tests — ensure allowed origins and options are correct. + * Run with: pnpm test -- cors + */ +import { getCorsOptions } from "../../../config/cors/cors-options"; +import { getAllowedOrigins } from "../../../config/cors/allowed-origins"; + +describe("CORS", () => { + const allowedOrigin = process.env.FRONTEND_URL || "http://localhost:3000"; + + describe("getAllowedOrigins", () => { + it("includes FRONTEND_URL", () => { + const origins = getAllowedOrigins(); + expect(origins).toContain(allowedOrigin); + }); + + it("never includes wildcard (unsafe with credentials)", () => { + const origins = getAllowedOrigins(); + expect(origins).not.toContain("*"); + }); + }); + + describe("getCorsOptions", () => { + it("returns credentials true and optionsSuccessStatus 204", () => { + const opts = getCorsOptions(); + expect(opts.credentials).toBe(true); + expect(opts.optionsSuccessStatus).toBe(204); + }); + + it("allows allowed origin via origin callback", (done) => { + const opts = getCorsOptions(); + const callback = opts.origin as ( + origin: string, + cb: (err: Error | null, allow?: boolean) => void + ) => void; + callback(allowedOrigin, (err, allow) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }); + }); + + it("rejects disallowed origin via origin callback", (done) => { + const opts = getCorsOptions(); + const callback = opts.origin as ( + origin: string, + cb: (err: Error | null, allow?: boolean) => void + ) => void; + callback("https://evil.example.com", (err) => { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/Origin not allowed|CORS/i); + done(); + }); + }); + + it("allows no origin (e.g. same-origin or server-to-server)", (done) => { + const opts = getCorsOptions(); + const callback = opts.origin as ( + origin: string | undefined, + cb: (err: Error | null, allow?: boolean) => void + ) => void; + callback(undefined, (err, allow) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }); + }); + + it("exposes X-Request-ID in exposedHeaders", () => { + const opts = getCorsOptions(); + expect(opts.exposedHeaders).toBeDefined(); + const exposed = (opts.exposedHeaders as string[]).map((h) => h.toLowerCase()); + expect(exposed).toContain("x-request-id"); + }); + }); +}); diff --git a/server/routes/__tests__/health-route.test.ts b/server/routes/__tests__/health-route.test.ts new file mode 100644 index 0000000..c5bd02d --- /dev/null +++ b/server/routes/__tests__/health-route.test.ts @@ -0,0 +1,68 @@ +/** + * Integration-style tests for server/routes/health-check-route.ts. + * Asserts GET /health, /live, /ready handlers are wired and return correct status/body. + * Uses direct controller calls with mocked health-service (no supertest). + */ +import { createApp } from "../../../app"; +import healthRoute from "../health-check-route"; +import errorHandler from "../../../middlewares/errors/error-handler"; +import { healthController } from "../../controllers"; +import { Request, Response } from "express"; + +jest.mock("../../services/health-service", () => ({ + checkDb: jest.fn().mockResolvedValue(true), +})); + +function buildApp() { + const app = createApp(); + app.use("/api/v1", healthRoute); + app.use(errorHandler); + return app; +} + +describe("Health routes", () => { + it("app mounts health routes without throwing", () => { + expect(() => buildApp()).not.toThrow(); + }); + + describe("health controller behavior (route logic)", () => { + it("healthCheck returns 200 and healthy when checkDb is true", async () => { + const req = {} as Request; + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + const res = { status, json } as unknown as Response; + await healthController.healthCheck(req, res); + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ status: "healthy", checks: { database: "up" } }), + }) + ); + }); + + it("live returns 200 and alive", async () => { + const req = {} as Request; + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + const res = { status, json } as unknown as Response; + await healthController.live(req, res); + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, data: expect.objectContaining({ status: "alive" }) }) + ); + }); + + it("ready delegates to healthCheck and returns 200 when DB up", async () => { + const req = {} as Request; + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + const res = { status, json } as unknown as Response; + await healthController.ready(req, res); + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, data: expect.objectContaining({ status: "healthy" }) }) + ); + }); + }); +}); diff --git a/server/routes/admin-route.ts b/server/routes/admin-route.ts index c05eec2..44f6231 100644 --- a/server/routes/admin-route.ts +++ b/server/routes/admin-route.ts @@ -1,8 +1,7 @@ import { Router } from "express"; -import { adminController } from '../controllers'; +import { adminController } from "../controllers"; import { requireAdmin } from "../../config/auth/auth-config"; - const router = Router(); // router.use(requireAdmin); @@ -10,7 +9,13 @@ const router = Router(); // Get all contact messages (admin only) router.get("/admin/contact-messages", adminController.getAllContactMessages); +// Get contact message by ID (admin only) +router.get("/admin/contact-messages/:id", adminController.getContactMessageById); + // Mark contact message as read (admin only) router.patch("/admin/contact-messages/:id/read", adminController.markContactMessageAsRead); +// Delete contact message (admin only) +router.delete("/admin/contact-messages/:id", adminController.deleteContactMessage); + export default router; diff --git a/server/routes/api-docs-route.ts b/server/routes/api-docs-route.ts new file mode 100644 index 0000000..1575fa4 --- /dev/null +++ b/server/routes/api-docs-route.ts @@ -0,0 +1,27 @@ +/** + * OpenAPI / Swagger UI — serves interactive API docs at GET /api-docs. + * Pass a plain object copy so Swagger UI's resolver does not hit undefined (e.g. reading 'users'). + */ +import type { ExpressApp, ExpressResponse } from "../../types/express-app"; +import swaggerUi from "swagger-ui-express"; +import spec from "../../config/openapi/spec"; + +function getSpecCopy(): Record { + return JSON.parse(JSON.stringify(spec)) as Record; +} + +export function mountApiDocs(app: ExpressApp): void { + app.use( + "/api-docs", + swaggerUi.serve, + swaggerUi.setup(getSpecCopy(), { + explorer: true, + deepLinking: true, + persistAuthorization: true, + docExpansion: "list", + }) + ); + app.get("/api-docs.json", (_req: unknown, res: ExpressResponse) => { + res.json(getSpecCopy()); + }); +} diff --git a/server/routes/auth-routes.ts b/server/routes/auth-routes.ts index 6cbf5b3..0e5f3e5 100644 --- a/server/routes/auth-routes.ts +++ b/server/routes/auth-routes.ts @@ -7,84 +7,74 @@ const authRoute = Router(); // Register endpoint authRoute.post("/register", async (req, res, next) => { + try { + // Check for existing user + const existingByUsername = await userService.getUserByUsername(req.body.username); + if (existingByUsername) { + return res.status(400).json({ message: "Username already exists" }); + } - try { - // Check for existing user - const existingByUsername = await userService.getUserByUsername(req.body.username); - if (existingByUsername) { - return res.status(400).json({ message: "Username already exists" }); - } - - const existingByEmail = await userService.getUserByEmail(req.body.email); - if (existingByEmail) { - return res.status(400).json({ message: "Email already exists" }); - } + const existingByEmail = await userService.getUserByEmail(req.body.email); + if (existingByEmail) { + return res.status(400).json({ message: "Email already exists" }); + } - // Create new user - const user = await userService.createUser({ - ...req.body, - password: await hashPassword(req.body.password), - }); + // Create new user + const user = await userService.createUser({ + ...req.body, + password: await hashPassword(req.body.password), + }); - // Log in the user after registration - req.login(user, (err) => { - if (err) return next(err); + // Log in the user after registration + req.login(user, (err) => { + if (err) return next(err); - // Remove password from response - const { password, ...userWithoutPassword } = user; - res.status(201).json(userWithoutPassword); - }); - } catch (error) { - next(error); - } + // Remove password from response + const { password, ...userWithoutPassword } = user; + res.status(201).json(userWithoutPassword); + }); + } catch (error) { + next(error); + } }); // Login endpoint authRoute.post("/login", (req, res, next) => { - - - passport.authenticate( - "local", - ( - err: any, - user: Express.User | false | null, - info: { message?: string } - ) => { - if (err) - return next(err); - if (!user) - return res.status(401).json({ message: "Invalid credentials" }); - - req.login(user, (err: any) => { - if (err) return next(err); - - // Remove password from response - const { password, ...userWithoutPassword } = user as Express.User; - res.status(200).json(userWithoutPassword); - }); - } - )(req, res, next); + passport.authenticate( + "local", + (err: any, user: Express.User | false | null, info: { message?: string }) => { + if (err) return next(err); + if (!user) return res.status(401).json({ message: "Invalid credentials" }); + + req.login(user, (err: any) => { + if (err) return next(err); + + // Remove password from response + const { password, ...userWithoutPassword } = user as Express.User; + res.status(200).json(userWithoutPassword); + }); + } + )(req, res, next); }); // Logout endpoint authRoute.post("/logout", (req, res, next) => { - - req.logout((err) => { - if (err) { - console.log("\n\n Logging out user:", err); - return next(err) - }; - res.sendStatus(200); - }); + req.logout((err) => { + if (err) { + console.log("\n\n Logging out user:", err); + return next(err); + } + res.sendStatus(200); + }); }); // Get current user endpoint authRoute.get("/current_user", (req, res) => { - if (!req.isAuthenticated()) return res.sendStatus(401); + if (!req.isAuthenticated()) return res.sendStatus(401); - // Remove password from response - const { password, ...userWithoutPassword } = req.user; - res.json(userWithoutPassword); + // Remove password from response + const { password, ...userWithoutPassword } = req.user; + res.json(userWithoutPassword); }); export default authRoute; diff --git a/server/routes/bookings-route.ts b/server/routes/bookings-route.ts index a370c24..7bdffc5 100644 --- a/server/routes/bookings-route.ts +++ b/server/routes/bookings-route.ts @@ -1,34 +1,33 @@ import { Router } from "express"; import { WebSocketServer } from "ws"; -import { bookingController } from '../controllers'; - +import { bookingController } from "../controllers"; export default function bookingRoutes(wss: WebSocketServer) { - const router = Router(); - - // Create a booking - router.post("/bookings", async (req, res) => (await bookingController.createBooking(req, res))(wss)); - - // Get bookings (user-specific or all based on user type) - router.get("/bookings", bookingController.getAllBookings); + const router = Router(); - // Get booking by ID - router.get("/bookings/:id", bookingController.getBookingById); + // Create a booking + router.post("/bookings", async (req, res) => + (await bookingController.createBooking(req, res))(wss) + ); - // Update booking status (attendant-specific) - router.patch("/bookings/attendant/:id/status", bookingController.updateBookingStatus); + // Get bookings (user-specific or all based on user type) + router.get("/bookings", bookingController.getAllBookings); + // Get booking by ID + router.get("/bookings/:id", bookingController.getBookingById); - // get specific booking for a user by id - router.get("/bookings/users/:userId", bookingController.getBookingsByUserId); + // Update booking status (attendant-specific) + router.patch("/bookings/attendant/:id/status", bookingController.updateBookingStatus); - // get booking by id - router.get("/bookings/:id", bookingController.getBookingById); + // get specific booking for a user by id + router.get("/bookings/users/:userId", bookingController.getBookingsByUserId); - // delete booking - // router.delete("/bookings/:id", bookingController.deleteBooking); + // get booking by id + router.get("/bookings/:id", bookingController.getBookingById); + // delete booking + // router.delete("/bookings/:id", bookingController.deleteBooking); - return router; + return router; } diff --git a/server/routes/contact-route.ts b/server/routes/contact-route.ts index cdab5a2..0dd3742 100644 --- a/server/routes/contact-route.ts +++ b/server/routes/contact-route.ts @@ -1,9 +1,11 @@ import { Router } from "express"; import { contactService } from "../services"; -import { ContactMessage, insertContactMessageSchema } from "../../config/database/schema/schema-types"; +import { + ContactMessage, + insertContactMessageSchema, +} from "../../config/database/schema/schema-types"; import { z } from "zod"; -import { contactController } from '../controllers'; - +import { contactController } from "../controllers"; const router = Router(); @@ -19,5 +21,4 @@ router.patch("/contact_messages/:id/read", contactController.markContactMessageA // Get unread contact messages count router.get("/contact_messages/unread_count", contactController.getUnreadContactMessagesCount); - export default router; diff --git a/server/routes/forum-route.ts b/server/routes/forum-route.ts index 5d092d4..da91b6f 100644 --- a/server/routes/forum-route.ts +++ b/server/routes/forum-route.ts @@ -1,26 +1,27 @@ import { Router } from "express"; import { WebSocketServer } from "ws"; -import { forumController } from '../controllers'; +import { forumController } from "../controllers"; export default function forumRoutes(wss: WebSocketServer) { - const router = Router(); + const router = Router(); - // Get all posts - router.get("/posts", forumController.getAllPosts); + // Get all posts + router.get("/posts", forumController.getAllPosts); - // Get post by ID - router.get("/posts/:id", forumController.getPostById); + // Get post by ID + router.get("/posts/:id", forumController.getPostById); - // Create a new post - router.post("/posts", async (req, res) => (await forumController.createPost(req, res))(wss)); + // Create a new post + router.post("/posts", async (req, res) => (await forumController.createPost(req, res))(wss)); - // Add comment to a post - router.post("/posts/:id/comments", async (req, res) => (await forumController.createComment(req, res))(wss)); + // Add comment to a post + router.post("/posts/:id/comments", async (req, res) => + (await forumController.createComment(req, res))(wss) + ); - // get comment by id - router.get("/posts/:id/comments", forumController.getCommentsByPostId); + // get comment by id + router.get("/posts/:id/comments", forumController.getCommentsByPostId); - - return router; + return router; } diff --git a/server/routes/gallery-route.ts b/server/routes/gallery-route.ts index 95fdcc9..1278b42 100644 --- a/server/routes/gallery-route.ts +++ b/server/routes/gallery-route.ts @@ -1,6 +1,5 @@ import { Router } from "express"; -import { galleryController } from '../controllers'; - +import { galleryController } from "../controllers"; const router = Router(); @@ -16,5 +15,4 @@ router.post("/galleries", galleryController.createGalleryItem); // get gallery item by category router.get("/galleries/categories/:category", galleryController.getGalleryItemsByCategory); - export default router; diff --git a/server/routes/health-check-route.ts b/server/routes/health-check-route.ts index c722043..c003abb 100644 --- a/server/routes/health-check-route.ts +++ b/server/routes/health-check-route.ts @@ -1,13 +1,20 @@ +/** + * Health check route — mounts GET /health under the API base path. + * No auth required; suitable for load balancers and CORS preflight. + */ import { Router } from "express"; -import { healthController } from '../controllers'; -import loginLimiter from '../../middlewares/login-limiter'; +import { healthController } from "../controllers"; const router = Router(); -//check if the server is running -router - .get("/health", healthController.healthCheck); - +router.get("/health", (req, res, next) => { + healthController.healthCheck(req, res).catch(next); +}); +router.get("/live", (req, res, next) => { + healthController.live(req, res).catch(next); +}); +router.get("/ready", (req, res, next) => { + healthController.ready(req, res).catch(next); +}); export default router; - diff --git a/server/routes/history-route.ts b/server/routes/history-route.ts index fb93c6f..25ad470 100644 --- a/server/routes/history-route.ts +++ b/server/routes/history-route.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { historyController } from '../controllers'; +import { historyController } from "../controllers"; const router = Router(); @@ -22,5 +22,4 @@ router.delete("/histories/:id", historyController.deleteHistoryContent); // Get history content by slug router.get("/histories/:id", historyController.getHistoryContentById); - export default router; diff --git a/server/routes/index.ts b/server/routes/index.ts index 585fa93..4af4cf9 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,7 +1,12 @@ -import type { Express } from "express"; +import path from "path"; +import express from "express"; +import type { ExpressApp } from "../../types/express-app"; import { createServer, type Server } from "http"; +import type { RequestListener } from "http"; import { WebSocketServer } from "ws"; import { configureAuth } from "../../config/auth/auth-config"; +import { mountApiDocs } from "./api-docs-route"; +import { notFoundMiddleware } from "../../middlewares/not-found"; // Import individual route modules import historyRoutes from "./history-route"; @@ -18,32 +23,35 @@ import likeRout from "./post_likes-route"; // socket function import { setupWebSocket } from "./websocket-route"; - -export async function registerRoutes(basePath: string, app: Express): Promise { - // Set up authentication routes - configureAuth(app); - - - // Create HTTP server - const httpServer = createServer(app); - - // Set up WebSocket server for real-time features - const wss = new WebSocketServer({ server: httpServer, path: '/ws' }); - - setupWebSocket(wss); - - // Register all route modules with the base path - app.use(basePath, historyRoutes); - app.use(basePath, galleryRoutes); - app.use(basePath, bookingRoutes(wss)); - app.use(basePath, forumRoutes(wss)); - app.use(basePath, contactRoutes); - app.use(basePath, adminRoutes); - app.use(basePath, authRoute); - app.use(basePath, healthRoute); - app.use(basePath, userRoute); - app.use(basePath, likeRout); - - // Start the server - return httpServer; +export async function registerRoutes(basePath: string, app: ExpressApp): Promise { + mountApiDocs(app); + // Serve landing page at GET / (public/index.html) + app.use(express.static(path.join(process.cwd(), "public"), { index: "index.html" })); + configureAuth(app); + + // Create HTTP server (app is ExpressApp; at runtime it is a full Express app / RequestListener) + const httpServer = createServer(app as unknown as RequestListener); + + // Set up WebSocket server for real-time features + const wss = new WebSocketServer({ server: httpServer, path: "/ws" }); + + setupWebSocket(wss); + + // Register all route modules with the base path + app.use(basePath, historyRoutes); + app.use(basePath, galleryRoutes); + app.use(basePath, bookingRoutes(wss)); + app.use(basePath, forumRoutes(wss)); + app.use(basePath, contactRoutes); + app.use(basePath, adminRoutes); + app.use(basePath, authRoute); + app.use(basePath, healthRoute); + app.use(basePath, userRoute); + app.use(basePath, likeRout); + + // 404 for unmatched API routes (Web API standard: consistent JSON error) + app.use(basePath, notFoundMiddleware); + + // Start the server + return httpServer; } diff --git a/server/routes/post_likes-route.ts b/server/routes/post_likes-route.ts index 50aa4e0..fc19acb 100644 --- a/server/routes/post_likes-route.ts +++ b/server/routes/post_likes-route.ts @@ -1,12 +1,10 @@ // routes/user.routes.ts -import { Router } from 'express'; -import { postLikesController } from '../controllers'; +import { Router } from "express"; +import { postLikesController } from "../controllers"; const router = Router(); - -router.post('/post_likes', postLikesController.likePost); -router.delete('/post_likes', postLikesController.unlikePost); - +router.post("/post_likes", postLikesController.likePost); +router.delete("/post_likes", postLikesController.unlikePost); export default router; diff --git a/server/routes/user-profile-route.ts b/server/routes/user-profile-route.ts index 54dc021..6a8d1a2 100644 --- a/server/routes/user-profile-route.ts +++ b/server/routes/user-profile-route.ts @@ -1,16 +1,15 @@ // routes/user.routes.ts -import { Router } from 'express'; -import { userController } from '../controllers'; +import { Router } from "express"; +import { userController } from "../controllers"; const router = Router(); - -router.get('/users/:id', userController.getUser); -router.get('/users/:username', userController.getUserByUsername); -router.post('/users', userController.createUser); -router.put('/users/:id', userController.updateUser); -router.get('/users', userController.getAllUsers); -router.delete('/users/:id', userController.deleteUserById); -router.delete("/users/:email", userController.deleteUserByEmail) +router.get("/users/:id", userController.getUser); +router.get("/users/:username", userController.getUserByUsername); +router.post("/users", userController.createUser); +router.put("/users/:id", userController.updateUser); +router.get("/users", userController.getAllUsers); +router.delete("/users/:id", userController.deleteUserById); +router.delete("/users/:email", userController.deleteUserByEmail); export default router; diff --git a/server/routes/websocket-route.ts b/server/routes/websocket-route.ts index 588f403..e43fc19 100644 --- a/server/routes/websocket-route.ts +++ b/server/routes/websocket-route.ts @@ -2,14 +2,14 @@ import { WebSocketServer } from "ws"; import WebSocket from "ws"; export function setupWebSocket(wss: WebSocketServer) { - wss.on('connection', (ws) => { - ws.on('message', (message) => { - // Broadcast message to all connected clients - wss.clients.forEach((client) => { - if (client !== ws && client.readyState === WebSocket.OPEN) { - client.send(message.toString()); - } - }); - }); + wss.on("connection", (ws) => { + ws.on("message", (message) => { + // Broadcast message to all connected clients + wss.clients.forEach((client) => { + if (client !== ws && client.readyState === WebSocket.OPEN) { + client.send(message.toString()); + } + }); }); + }); } diff --git a/server/services/__tests__/booking-service.test.ts b/server/services/__tests__/booking-service.test.ts new file mode 100644 index 0000000..fb9e985 --- /dev/null +++ b/server/services/__tests__/booking-service.test.ts @@ -0,0 +1,117 @@ +/** + * Unit tests for server/services/booking-service.ts. + * Covers getBookingById, getBookingsByUserId, getAllBookings (validation and status), + * updateBookingStatus with mocked db and validateId. + */ +import { BookingService } from "../booking-service"; +import { NotFoundError, ValidationError } from "../../../middlewares/errors/error-handler"; +import { eq } from "drizzle-orm"; +import { bookings } from "../../../config/database/schema/tables"; + +jest.mock("../../utils/validations/uuid-validator"); + +const mockDb = { + query: { + bookings: { findFirst: jest.fn(), findMany: jest.fn() }, + }, + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), +}; + +describe("BookingService", () => { + let bookingService: BookingService; + const mockBaseService = { + withErrorHandling: jest.fn(async (fn: () => Promise) => fn()), + db: mockDb, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockDb.where.mockReturnValue({ returning: jest.fn() }); + bookingService = new BookingService(); + Object.assign(bookingService, mockBaseService); + }); + + describe("getBookingById", () => { + it("returns booking when found", async () => { + const validateId = require("../../utils/validations/uuid-validator").validateId; + validateId.mockReturnValue("valid-id"); + const mockBooking = { id: "valid-id", email: "u@e.com" }; + mockDb.query.bookings.findFirst.mockResolvedValue(mockBooking); + + const result = await bookingService.getBookingById("valid-id"); + expect(result).toEqual(mockBooking); + }); + + it("returns null when not found", async () => { + const validateId = require("../../utils/validations/uuid-validator").validateId; + validateId.mockReturnValue("valid-id"); + mockDb.query.bookings.findFirst.mockResolvedValue(null); + + const result = await bookingService.getBookingById("valid-id"); + expect(result).toBeNull(); + }); + + it("throws ValidationError for invalid id", async () => { + const validateId = require("../../utils/validations/uuid-validator").validateId; + validateId.mockImplementation(() => { + throw new ValidationError("Invalid booking id"); + }); + await expect(bookingService.getBookingById("invalid")).rejects.toThrow(ValidationError); + }); + }); + + describe("getAllBookings", () => { + it("throws ValidationError for invalid status", async () => { + await expect(bookingService.getAllBookings("invalid-status")).rejects.toThrow( + ValidationError + ); + }); + + it("returns data and total when status is valid", async () => { + const mockData = [{ id: "b1" }]; + const mockTotal = [{ count: 1 }]; + mockDb.query.bookings.findMany.mockResolvedValue(mockData); + mockDb.where.mockResolvedValue(mockTotal); + + const result = await bookingService.getAllBookings("pending", 10, 0); + expect(result.data).toEqual(mockData); + expect(result.total).toBe(1); + }); + }); + + describe("updateBookingStatus", () => { + it("throws ValidationError for invalid status", async () => { + const validateId = require("../../utils/validations/uuid-validator").validateId; + validateId.mockReturnValue("valid-id"); + await expect( + bookingService.updateBookingStatus("valid-id", "invalid") + ).rejects.toThrow(ValidationError); + }); + + it("throws NotFoundError when no row updated", async () => { + const validateId = require("../../utils/validations/uuid-validator").validateId; + validateId.mockReturnValue("valid-id"); + const returningFn = jest.fn().mockResolvedValue([]); + mockDb.where.mockReturnValue({ returning: returningFn }); + + await expect( + bookingService.updateBookingStatus("valid-id", "confirmed") + ).rejects.toThrow(NotFoundError); + }); + + it("returns updated booking when found", async () => { + const validateId = require("../../utils/validations/uuid-validator").validateId; + validateId.mockReturnValue("valid-id"); + const updated = { id: "valid-id", status: "confirmed" }; + mockDb.where.mockReturnValue({ returning: jest.fn().mockResolvedValue([updated]) }); + + const result = await bookingService.updateBookingStatus("valid-id", "confirmed"); + expect(result).toEqual(updated); + expect(mockDb.where).toHaveBeenCalledWith(eq(bookings.id, "valid-id")); + }); + }); +}); diff --git a/server/services/__tests__/contact-service.test.ts b/server/services/__tests__/contact-service.test.ts new file mode 100644 index 0000000..bfb87bf --- /dev/null +++ b/server/services/__tests__/contact-service.test.ts @@ -0,0 +1,147 @@ +/** + * Unit tests for server/services/contact-service.ts. + * Covers getAllContactMessages, createContactMessage, markContactMessageAsRead, + * getUnreadContactMessagesCount with mocked db and validators. + */ +import { ContactService } from "../contact-service"; +import { NotFoundError, ValidationError } from "../../../middlewares/errors/error-handler"; +import { eq } from "drizzle-orm"; +import { contactMessages } from "../../../config/database/schema/tables"; + +jest.mock("../../utils/validations/uuid-validator"); +jest.mock("../../utils/validations/email-validation", () => ({ + validateEmail: jest.fn(), + sanitizeString: (str: string) => (str || "").trim().replace(/\s+/g, " "), + slugify: (t: string) => t, +})); + +describe("ContactService", () => { + let contactService: ContactService; + const mockDb = { + query: { + contactMessages: { + findMany: jest.fn(), + }, + }, + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + from: jest.fn(), + }; + + const mockBaseService = { + withErrorHandling: jest.fn(async (fn: () => Promise) => fn()), + db: mockDb, + }; + + beforeEach(() => { + jest.clearAllMocks(); + contactService = new ContactService(); + Object.assign(contactService, mockBaseService); + }); + + describe("getAllContactMessages", () => { + it("returns data, total, and hasMore", async () => { + const mockData = [{ id: "1", message: "Hi" }]; + const mockTotal = [{ count: 1 }]; + mockDb.query.contactMessages.findMany.mockResolvedValue(mockData); + mockDb.select.mockReturnValue({ + from: jest.fn().mockResolvedValue(mockTotal), + }); + + const result = await contactService.getAllContactMessages(10, 0); + expect(result.data).toEqual(mockData); + expect(result.total).toBe(1); + expect(result.hasMore).toBe(false); + }); + }); + + describe("createContactMessage", () => { + it("throws ValidationError when required fields are missing", async () => { + await expect(contactService.createContactMessage({})).rejects.toThrow(ValidationError); + await expect( + contactService.createContactMessage({ fullName: "A", email: "a@b.com" } as any) + ).rejects.toThrow(ValidationError); + }); + + it("throws when validateEmail throws", async () => { + const validateEmail = require("../../utils/validations/email-validation").validateEmail; + validateEmail.mockImplementation(() => { + throw new ValidationError("Invalid email format"); + }); + await expect( + contactService.createContactMessage({ + fullName: "A", + email: "bad", + message: "Hi", + subject: "Sub", + }) + ).rejects.toThrow(ValidationError); + }); + + it("inserts and returns message when valid", async () => { + const validateEmail = require("../../utils/validations/email-validation").validateEmail; + validateEmail.mockImplementation(() => { }); + const created = { + id: "new-id", + fullName: "Test", + email: "test@example.com", + message: "Hello", + subject: "Sub", + isRead: false, + createdAt: new Date(), + }; + mockDb.returning.mockResolvedValue([created]); + + const result = await contactService.createContactMessage({ + fullName: "Test", + email: "test@example.com", + message: "Hello", + subject: "Sub", + }); + expect(result).toEqual(created); + expect(mockDb.insert).toHaveBeenCalled(); + }); + }); + + describe("markContactMessageAsRead", () => { + it("throws NotFoundError when no row updated", async () => { + const validateId = require("../../utils/validations/uuid-validator").validateId; + validateId.mockReturnValue("valid-id"); + mockDb.returning.mockResolvedValue([]); + + await expect( + contactService.markContactMessageAsRead("123e4567-e89b-12d3-a456-426614174000") + ).rejects.toThrow(NotFoundError); + }); + + it("returns updated message when found", async () => { + const validateId = require("../../utils/validations/uuid-validator").validateId; + validateId.mockReturnValue("valid-id"); + const updated = { id: "valid-id", isRead: true }; + mockDb.returning.mockResolvedValue([updated]); + + const result = await contactService.markContactMessageAsRead("valid-id"); + expect(result).toEqual(updated); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith({ isRead: true }); + expect(mockDb.where).toHaveBeenCalledWith(eq(contactMessages.id, "valid-id")); + }); + }); + + describe("getUnreadContactMessagesCount", () => { + it("returns count from db", async () => { + mockDb.from = jest.fn().mockReturnValue({ + where: jest.fn().mockResolvedValue([{ count: 5 }]), + }); + mockDb.select.mockReturnValue(mockDb); + + const result = await contactService.getUnreadContactMessagesCount(); + expect(result).toBe(5); + }); + }); +}); diff --git a/server/services/__tests__/health-service.test.ts b/server/services/__tests__/health-service.test.ts new file mode 100644 index 0000000..9274166 --- /dev/null +++ b/server/services/__tests__/health-service.test.ts @@ -0,0 +1,37 @@ +/** + * Unit tests for server/services/health-service.ts. + * Covers checkDb() with mocked pg pool (success and failure). + */ +import { checkDb } from "../health-service"; + +jest.mock("../../../config/database/db-connection", () => { + const mockQuery = jest.fn(); + return { + __esModule: true, + default: { + pool: { + query: mockQuery, + }, + }, + }; +}); + +const dbConnection = require("../../../config/database/db-connection"); +const pool = dbConnection.default.pool; + +describe("HealthService / checkDb", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns true when pool.query resolves", async () => { + pool.query.mockResolvedValue(undefined); + await expect(checkDb()).resolves.toBe(true); + expect(pool.query).toHaveBeenCalledWith("SELECT 1"); + }); + + it("returns false when pool.query rejects", async () => { + pool.query.mockRejectedValue(new Error("connection refused")); + await expect(checkDb()).resolves.toBe(false); + }); +}); diff --git a/server/services/base-service.ts b/server/services/base-service.ts index 075d703..41f8581 100644 --- a/server/services/base-service.ts +++ b/server/services/base-service.ts @@ -6,33 +6,33 @@ import { withErrorHandling } from "../../middlewares/errors/error-handler"; const { db, pool } = dbPool; export abstract class BaseService { - protected db = db; - protected connectionPool: Pool; + protected db = db; + protected connectionPool: Pool; - constructor() { - this.connectionPool = pool as Pool; - } + constructor() { + this.connectionPool = pool as Pool; + } - // Health check method - async healthCheck(): Promise { - try { - await this.connectionPool.query('SELECT 1'); - return true; - } catch (error) { - console.error('Database health check failed:', error); - return false; - } + // Health check method + async healthCheck(): Promise { + try { + await this.connectionPool.query("SELECT 1"); + return true; + } catch (error) { + console.error("Database health check failed:", error); + return false; } + } - // Cleanup method for graceful shutdown - async cleanup(): Promise { - try { - await this.connectionPool.end(); - console.log('Database connection pool closed'); - } catch (error) { - console.error('Error closing database connection pool:', error); - } + // Cleanup method for graceful shutdown + async cleanup(): Promise { + try { + await this.connectionPool.end(); + console.log("Database connection pool closed"); + } catch (error) { + console.error("Error closing database connection pool:", error); } + } - protected withErrorHandling = withErrorHandling; + protected withErrorHandling = withErrorHandling; } diff --git a/server/services/booking-service.ts b/server/services/booking-service.ts index 19a122a..70271a7 100644 --- a/server/services/booking-service.ts +++ b/server/services/booking-service.ts @@ -8,136 +8,141 @@ import { bookings } from "../../config/database/schema/tables"; import { bookingStatus } from "../../config/database/schema/enum"; export class BookingService extends BaseService { - - async getBookingById(id: string): Promise { - validateId(id, 'booking id'); - - return this.withErrorHandling(async () => { - const result = await this.db.query.bookings.findFirst({ - where: eq(bookings.id, id) - }); - return result || null; - }, 'getBookingById'); + async getBookingById(id: string): Promise { + validateId(id, "booking id"); + + return this.withErrorHandling(async () => { + const result = await this.db.query.bookings.findFirst({ + where: eq(bookings.id, id), + }); + return result || null; + }, "getBookingById"); + } + + async getBookingsByUserId( + userId: string, + limit: number = 20, + offset: number = 0 + ): Promise<{ + data: Booking[]; + total: number; + hasMore: boolean; + }> { + validateId(userId, "user id"); + + return this.withErrorHandling(async () => { + const [data, totalResult] = await Promise.all([ + this.db.query.bookings.findMany({ + where: eq(bookings.userId, userId), + limit, + offset, + orderBy: desc(bookings.visitDate), + }), + this.db.select({ count: count() }).from(bookings).where(eq(bookings.userId, userId)), + ]); + + const total = totalResult[0]?.count || 0; + const hasMore = offset + limit < total; + + return { data, total, hasMore }; + }, "getBookingsByUserId"); + } + + async getAllBookings( + status?: string, + limit: number = 50, + offset: number = 0 + ): Promise<{ + data: Booking[]; + total: number; + hasMore: boolean; + }> { + // Get valid enum values from the schema definition + const validStatuses = bookingStatus.enumValues; + + if (status && !validStatuses.includes(status as (typeof validStatuses)[number])) { + throw new ValidationError(`Invalid status. Must be one of: ${validStatuses.join(", ")}`); } - async getBookingsByUserId(userId: string, limit: number = 20, offset: number = 0): Promise<{ - data: Booking[]; - total: number; - hasMore: boolean; - }> { - validateId(userId, 'user id'); - - return this.withErrorHandling(async () => { - const [data, totalResult] = await Promise.all([ - this.db.query.bookings.findMany({ - where: eq(bookings.userId, userId), - limit, - offset, - orderBy: desc(bookings.visitDate) - }), - this.db.select({ count: count() }) - .from(bookings) - .where(eq(bookings.userId, userId)) - ]); - - const total = totalResult[0]?.count || 0; - const hasMore = offset + limit < total; - - return { data, total, hasMore }; - }, 'getBookingsByUserId'); + return this.withErrorHandling(async () => { + const whereCondition = status + ? eq(bookings.status, status as (typeof bookingStatus.enumValues)[number]) + : undefined; + + const [data, totalResult] = await Promise.all([ + this.db.query.bookings.findMany({ + where: whereCondition, + limit, + offset, + orderBy: desc(bookings.visitDate), + with: { + user: true, // Include related author data + }, + }), + this.db + .select({ count: count() }) + .from(bookings) + .where(whereCondition || sql`true`), + ]); + + const total = totalResult[0]?.count || 0; + const hasMore = offset + limit < total; + + return { data, total, hasMore }; + }, "getAllBookings"); + } + + async createBooking(data: Partial): Promise { + if (!data.email || !data.visitDate) { + throw new ValidationError("UserId and visitDate are required"); } - async getAllBookings( - status?: string, - limit: number = 50, - offset: number = 0 - ): Promise<{ - data: Booking[]; - total: number; - hasMore: boolean; - }> { - // Get valid enum values from the schema definition - const validStatuses = bookingStatus.enumValues; - - if (status && !validStatuses.includes(status as typeof validStatuses[number])) { - throw new ValidationError(`Invalid status. Must be one of: ${validStatuses.join(', ')}`); - } - - return this.withErrorHandling(async () => { - const whereCondition = status ? eq(bookings.status, status as typeof bookingStatus.enumValues[number]) : undefined; - - const [data, totalResult] = await Promise.all([ - this.db.query.bookings.findMany({ - where: whereCondition, - limit, - offset, - orderBy: desc(bookings.visitDate), - with: { - user: true // Include related author data - } - }), - this.db.select({ count: count() }) - .from(bookings) - .where(whereCondition || sql`true`) - ]); - - const total = totalResult[0]?.count || 0; - const hasMore = offset + limit < total; - - return { data, total, hasMore }; - }, 'getAllBookings'); + // Validate visit date is in the future + const visitDate = new Date(data.visitDate); + if (new Date(visitDate).getTime() <= new Date().getTime()) { + throw new ValidationError("Visit date must be in the future"); } - async createBooking(data: Partial): Promise { - if (!data.email || !data.visitDate) { - throw new ValidationError('UserId and visitDate are required'); - } - - // Validate visit date is in the future - const visitDate = new Date(data.visitDate); - if (new Date(visitDate).getTime() <= new Date().getTime()) { - throw new ValidationError('Visit date must be in the future'); - } - - const sanitizedData: Partial = { - ...data, - visitDate: new Date(data.visitDate).toISOString(), - status: data.status || 'pending', - createdAt: new Date(), - updatedAt: new Date() - }; - - return this.withErrorHandling(async () => { - const [booking] = await this.db.insert(bookings) - .values(sanitizedData as Booking) - .returning(); - return booking; - }, 'createBooking'); + const sanitizedData: Partial = { + ...data, + visitDate: new Date(data.visitDate).toISOString(), + status: data.status || "pending", + createdAt: new Date(), + updatedAt: new Date(), + }; + + return this.withErrorHandling(async () => { + const [booking] = await this.db + .insert(bookings) + .values(sanitizedData as Booking) + .returning(); + return booking; + }, "createBooking"); + } + + async updateBookingStatus(id: string, status: string): Promise { + validateId(id, "booking id"); + + const validStatuses = bookingStatus.enumValues; + if (!validStatuses.includes(status as (typeof validStatuses)[number])) { + throw new ValidationError(`Invalid status. Must be one of: ${validStatuses.join(", ")}`); } - async updateBookingStatus(id: string, status: string): Promise { - validateId(id, 'booking id'); - - const validStatuses = bookingStatus.enumValues; - if (!validStatuses.includes(status as typeof validStatuses[number])) { - throw new ValidationError(`Invalid status. Must be one of: ${validStatuses.join(', ')}`); - } - - return this.withErrorHandling(async () => { - const [updatedBooking] = await this.db - .update(bookings) - .set({ - status: status as typeof bookingStatus.enumValues[number], - updatedAt: new Date() - }) - .where(eq(bookings.id, id)) - .returning(); - - if (!updatedBooking) { - throw new NotFoundError('Booking', id); - } - - return updatedBooking; - }, 'updateBookingStatus'); - } + return this.withErrorHandling(async () => { + const [updatedBooking] = await this.db + .update(bookings) + .set({ + status: status as (typeof bookingStatus.enumValues)[number], + updatedAt: new Date(), + }) + .where(eq(bookings.id, id)) + .returning(); + + if (!updatedBooking) { + throw new NotFoundError("Booking", id); + } + + return updatedBooking; + }, "updateBookingStatus"); + } } diff --git a/server/services/contact-service.ts b/server/services/contact-service.ts index f24ab98..bc4297b 100644 --- a/server/services/contact-service.ts +++ b/server/services/contact-service.ts @@ -8,87 +8,125 @@ import { BaseService } from "./base-service"; import { contactMessages } from "../../config/database/schema/tables"; export class ContactService extends BaseService { + async getAllContactMessages( + limit: number = 50, + offset: number = 0 + ): Promise<{ + data: ContactMessage[]; + total: number; + hasMore: boolean; + }> { + return this.withErrorHandling(async () => { + const [data, totalResult] = await Promise.all([ + this.db.query.contactMessages.findMany({ + limit, + offset, + orderBy: desc(contactMessages.createdAt), + with: { + user: { + columns: { + id: true, + fullName: true, + email: true, + }, + }, + }, + }), + this.db.select({ count: count() }).from(contactMessages), + ]); - async getAllContactMessages(limit: number = 50, offset: number = 0): Promise<{ - data: ContactMessage[]; - total: number; - hasMore: boolean; - }> { - return this.withErrorHandling(async () => { - const [data, totalResult] = await Promise.all([ - this.db.query.contactMessages.findMany({ - limit, - offset, - orderBy: desc(contactMessages.createdAt), - with: { - user: { - columns: { - id: true, - fullName: true, - email: true - } - } - } - }), - this.db.select({ count: count() }).from(contactMessages) - ]); - - const total = totalResult[0]?.count || 0; - const hasMore = offset + limit < total; - - return { data, total, hasMore }; - }, 'getAllContactMessages'); - } + const total = totalResult[0]?.count || 0; + const hasMore = offset + limit < total; + + return { data, total, hasMore }; + }, "getAllContactMessages"); + } - async createContactMessage(data: Partial): Promise { - if (!data.fullName || !data.email || !data.message || !data.subject) { - throw new ValidationError('Name, subject, email, and message are required'); - } - - validateEmail(data.email); - - const sanitizedData = { - ...data, - name: sanitizeString(data.fullName), - email: sanitizeString(data.email.toLowerCase()), - message: sanitizeString(data.message), - isRead: false, - createdAt: new Date() - }; - - return this.withErrorHandling(async () => { - const [message] = await this.db.insert(contactMessages).values(sanitizedData as ContactMessage).returning(); - return message; - }, 'createContactMessage'); + async createContactMessage(data: Partial): Promise { + if (!data.fullName || !data.email || !data.message || !data.subject) { + throw new ValidationError("Name, subject, email, and message are required"); } - async markContactMessageAsRead(id: string): Promise { - validateId(id, 'contact message id'); + validateEmail(data.email); - return this.withErrorHandling(async () => { - const [updatedMessage] = await this.db - .update(contactMessages) - .set({ isRead: true }) - .where(eq(contactMessages.id, id)) - .returning(); + const sanitizedData = { + ...data, + name: sanitizeString(data.fullName), + email: sanitizeString(data.email.toLowerCase()), + message: sanitizeString(data.message), + isRead: false, + createdAt: new Date(), + }; - if (!updatedMessage) { - throw new NotFoundError('Contact message', id); - } + return this.withErrorHandling(async () => { + const [message] = await this.db + .insert(contactMessages) + .values(sanitizedData as ContactMessage) + .returning(); + return message; + }, "createContactMessage"); + } - return updatedMessage; - }, 'markContactMessageAsRead'); - } + async markContactMessageAsRead(id: string): Promise { + validateId(id, "contact message id"); - // Bulk operations for better performance - async getUnreadContactMessagesCount(): Promise { - return this.withErrorHandling(async () => { - const result = await this.db - .select({ count: count() }) - .from(contactMessages) - .where(eq(contactMessages.isRead, false)); + return this.withErrorHandling(async () => { + const [updatedMessage] = await this.db + .update(contactMessages) + .set({ isRead: true }) + .where(eq(contactMessages.id, id)) + .returning(); - return result[0]?.count || 0; - }, 'getUnreadContactMessagesCount'); - } -} \ No newline at end of file + if (!updatedMessage) { + throw new NotFoundError("Contact message", id); + } + + return updatedMessage; + }, "markContactMessageAsRead"); + } + + // Bulk operations for better performance + async getUnreadContactMessagesCount(): Promise { + return this.withErrorHandling(async () => { + const result = await this.db + .select({ count: count() }) + .from(contactMessages) + .where(eq(contactMessages.isRead, false)); + + return result[0]?.count || 0; + }, "getUnreadContactMessagesCount"); + } + + async getContactMessageById(id: string): Promise { + validateId(id, "contact message id"); + + return this.withErrorHandling(async () => { + const [message] = await this.db + .select() + .from(contactMessages) + .where(eq(contactMessages.id, id)) + .limit(1); + + if (!message) { + throw new NotFoundError("Contact message", id); + } + + return message; + }, "getContactMessageById"); + } + + async deleteContactMessage(id: string): Promise { + validateId(id, "contact message id"); + + return this.withErrorHandling(async () => { + const deleted = await this.db + .delete(contactMessages) + .where(eq(contactMessages.id, id)) + .returning({ id: contactMessages.id }); + + if (deleted.length === 0) { + throw new NotFoundError("Contact message", id); + } + }, "deleteContactMessage"); + } +} diff --git a/server/services/forum-service.ts b/server/services/forum-service.ts index d649c32..549436b 100644 --- a/server/services/forum-service.ts +++ b/server/services/forum-service.ts @@ -8,174 +8,180 @@ import { BaseService } from "./base-service"; import { comments, posts } from "../../config/database/schema/tables"; export class ForumService extends BaseService { - - async getPostById(id: string): Promise { - validateId(id, 'post id'); - - return this.withErrorHandling(async () => { - const result = await this.db.query.posts.findFirst({ - where: eq(posts.id, id), - with: { - author: { - columns: { - id: true, - username: true, - email: false - } - }, - comments: { - with: { - author: { - columns: { - id: true, - username: true, - email: false - } - } - }, - orderBy: asc(comments.createdAt) - } - } - }); - return result || null; - }, 'getPostById'); - } - - async getAllPosts( - isAttendantOnly?: boolean, - limit: number = 20, - offset: number = 0 - ): Promise<{ - data: Post[]; - total: number; - hasMore: boolean; - }> { - return this.withErrorHandling(async () => { - - const whereCondition = typeof isAttendantOnly === 'boolean' - ? eq(posts.isAttendantOnly, isAttendantOnly) - : undefined; - - const [data, totalResult] = await Promise.all([ - this.db.query.posts.findMany({ - where: whereCondition, - limit, - offset, - orderBy: desc(posts.createdAt), - with: { - author: { - columns: { - id: true, - fullName: true, - email: false - } - }, - comments: { - with: { - author: { - columns: { - id: true, - username: true, - fullName: true, - email: false - } - } - }, - limit: 3, // Only load first 3 comments for performance - orderBy: desc(comments.createdAt) - } - } - }), - this.db.select({ count: count() }) - .from(posts) - .where(whereCondition || sql`true`) - ]); - - const total = totalResult[0]?.count || 0; - const hasMore = offset + limit < total; - - return { data, total, hasMore }; - }, 'getAllPosts'); + async getPostById(id: string): Promise { + validateId(id, "post id"); + + return this.withErrorHandling(async () => { + const result = await this.db.query.posts.findFirst({ + where: eq(posts.id, id), + with: { + author: { + columns: { + id: true, + username: true, + email: false, + }, + }, + comments: { + with: { + author: { + columns: { + id: true, + username: true, + email: false, + }, + }, + }, + orderBy: asc(comments.createdAt), + }, + }, + }); + return result || null; + }, "getPostById"); + } + + async getAllPosts( + isAttendantOnly?: boolean, + limit: number = 20, + offset: number = 0 + ): Promise<{ + data: Post[]; + total: number; + hasMore: boolean; + }> { + return this.withErrorHandling(async () => { + const whereCondition = + typeof isAttendantOnly === "boolean" + ? eq(posts.isAttendantOnly, isAttendantOnly) + : undefined; + + const [data, totalResult] = await Promise.all([ + this.db.query.posts.findMany({ + where: whereCondition, + limit, + offset, + orderBy: desc(posts.createdAt), + with: { + author: { + columns: { + id: true, + fullName: true, + email: false, + }, + }, + comments: { + with: { + author: { + columns: { + id: true, + username: true, + fullName: true, + email: false, + }, + }, + }, + limit: 3, // Only load first 3 comments for performance + orderBy: desc(comments.createdAt), + }, + }, + }), + this.db + .select({ count: count() }) + .from(posts) + .where(whereCondition || sql`true`), + ]); + + const total = totalResult[0]?.count || 0; + const hasMore = offset + limit < total; + + return { data, total, hasMore }; + }, "getAllPosts"); + } + + async createPost(data: Omit) { + try { + if (!data.title || !data.content || !data.authorId) { + throw new ValidationError("Title, content, and authorId are required"); + } + + const sanitizedData: Omit = { + ...data, + authorId: data?.authorId, + content: sanitizeString(data.content), + title: sanitizeString(data.title), + isAttendantOnly: data.isAttendantOnly || false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + return this.withErrorHandling(async () => { + const [post] = await this.db + .insert(posts) + .values(sanitizedData as Post) + .returning(); + return post; + }, "createPost"); + } catch (error: any) { + console.log("\n\n error creating post on forum: ", error); + return { error: error?.message || "Internal Server Error" }; } - - async createPost(data: Omit) { - try { - if (!data.title || !data.content || !data.authorId) { - throw new ValidationError('Title, content, and authorId are required'); - } - - const sanitizedData: Omit = { - ...data, - authorId: data?.authorId, - content: sanitizeString(data.content), - title: sanitizeString(data.title), - isAttendantOnly: data.isAttendantOnly || false, - createdAt: new Date(), - updatedAt: new Date() - }; - - return this.withErrorHandling(async () => { - const [post] = await this.db.insert(posts) - .values(sanitizedData as Post) - .returning(); - return post; - }, 'createPost'); - } catch (error: any) { - console.log("\n\n error creating post on forum: ", error); - return { error: error?.message || 'Internal Server Error' }; - } + } + + async getCommentsByPostId( + postId: string, + limit: number = 50, + offset: number = 0 + ): Promise<{ + data: Comment[]; + total: number; + hasMore: boolean; + }> { + validateId(postId, "post id"); + + return this.withErrorHandling(async () => { + const [data, totalResult] = await Promise.all([ + this.db.query.comments.findMany({ + where: eq(comments.postId, postId), + limit, + offset, + orderBy: asc(comments.createdAt), + with: { + author: { + columns: { + id: true, + username: true, + email: false, + }, + }, + }, + }), + this.db.select({ count: count() }).from(comments).where(eq(comments.postId, postId)), + ]); + + const total = totalResult[0]?.count || 0; + const hasMore = offset + limit < total; + + return { data, total, hasMore }; + }, "getCommentsByPostId"); + } + + async createComment(data: Partial): Promise { + if (!data.content || !data.authorId || !data.postId) { + throw new ValidationError("Content, authorId, and postId are required"); } - async getCommentsByPostId(postId: string, limit: number = 50, offset: number = 0): Promise<{ - data: Comment[]; - total: number; - hasMore: boolean; - }> { - validateId(postId, 'post id'); - - return this.withErrorHandling(async () => { - const [data, totalResult] = await Promise.all([ - this.db.query.comments.findMany({ - where: eq(comments.postId, postId), - limit, - offset, - orderBy: asc(comments.createdAt), - with: { - author: { - columns: { - id: true, - username: true, - email: false - } - } - } - }), - this.db.select({ count: count() }) - .from(comments) - .where(eq(comments.postId, postId)) - ]); - - const total = totalResult[0]?.count || 0; - const hasMore = offset + limit < total; - - return { data, total, hasMore }; - }, 'getCommentsByPostId'); - } - - async createComment(data: Partial): Promise { - if (!data.content || !data.authorId || !data.postId) { - throw new ValidationError('Content, authorId, and postId are required'); - } - - const sanitizedData = { - ...data, - content: sanitizeString(data.content), - authorId: data?.authorId, - }; - - return this.withErrorHandling(async () => { - const [comment] = await this.db.insert(comments).values(sanitizedData as Comment).returning(); - return comment; - }, 'createComment'); - } + const sanitizedData = { + ...data, + content: sanitizeString(data.content), + authorId: data?.authorId, + }; + + return this.withErrorHandling(async () => { + const [comment] = await this.db + .insert(comments) + .values(sanitizedData as Comment) + .returning(); + return comment; + }, "createComment"); + } } diff --git a/server/services/gallery-service.ts b/server/services/gallery-service.ts index d181573..cc15931 100644 --- a/server/services/gallery-service.ts +++ b/server/services/gallery-service.ts @@ -8,95 +8,104 @@ import { BaseService } from "./base-service"; import { galleryItems } from "../../config/database/schema/tables"; export class GalleryService extends BaseService { + async getGalleryItemById(id: string): Promise { + validateId(id, "gallery item id"); - async getGalleryItemById(id: string): Promise { - validateId(id, 'gallery item id'); + return this.withErrorHandling(async () => { + const result = await this.db.query.galleryItems.findFirst({ + where: eq(galleryItems.id, id), + }); + return result || null; + }, "getGalleryItemById"); + } - return this.withErrorHandling(async () => { - const result = await this.db.query.galleryItems.findFirst({ - where: eq(galleryItems.id, id) - }); - return result || null; - }, 'getGalleryItemById'); + async getAllGalleryItems( + category?: string, + limit: number = 20, + offset: number = 0 + ): Promise<{ + data: GalleryItem[]; + total: number; + hasMore: boolean; + }> { + if (limit < 1 || limit > 100) { + throw new ValidationError("Limit must be between 1 and 100"); } - async getAllGalleryItems(category?: string, limit: number = 20, offset: number = 0): Promise<{ - data: GalleryItem[]; - total: number; - hasMore: boolean; - }> { - if (limit < 1 || limit > 100) { - throw new ValidationError('Limit must be between 1 and 100'); - } + return this.withErrorHandling(async () => { + const whereCondition = category ? eq(galleryItems.category, category) : undefined; - return this.withErrorHandling(async () => { - const whereCondition = category ? eq(galleryItems.category, category) : undefined; + const [data, totalResult] = await Promise.all([ + this.db.query.galleryItems.findMany({ + where: whereCondition, + limit, + offset, + orderBy: asc(galleryItems.title), + }), + this.db + .select({ count: count() }) + .from(galleryItems) + .where(whereCondition || sql`true`), + ]); - const [data, totalResult] = await Promise.all([ - this.db.query.galleryItems.findMany({ - where: whereCondition, - limit, - offset, - orderBy: asc(galleryItems.title) - }), - this.db.select({ count: count() }) - .from(galleryItems) - .where(whereCondition || sql`true`) - ]); + const total = totalResult[0]?.count || 0; + const hasMore = offset + limit < total; - const total = totalResult[0]?.count || 0; - const hasMore = offset + limit < total; + return { data, total, hasMore }; + }, "getAllGalleryItems"); + } - return { data, total, hasMore }; - }, 'getAllGalleryItems'); + async getGalleryItemsByCategory( + category: string, + limit: number = 20, + offset: number = 0 + ): Promise<{ + data: GalleryItem[]; + total: number; + hasMore: boolean; + }> { + if (limit < 0 || limit > 50) { + throw new ValidationError("Limit must be between 1 and 50"); } - async getGalleryItemsByCategory(category: string, limit: number = 20, offset: number = 0): Promise<{ - data: GalleryItem[]; - total: number; - hasMore: boolean; - }> { - if (limit < 0 || limit > 50) { - throw new ValidationError('Limit must be between 1 and 50'); - } + validateId(category, "category"); - validateId(category, 'category'); + return this.withErrorHandling(async () => { + const [data, totalResult] = await Promise.all([ + this.db.query.galleryItems.findMany({ + where: eq(galleryItems.category, category), + limit, + offset, + orderBy: asc(galleryItems.title), + }), + this.db + .select({ count: count() }) + .from(galleryItems) + .where(eq(galleryItems.category, category)), + ]); + const total = totalResult[0]?.count ?? 0; + const hasMore = offset + limit < total; - return this.withErrorHandling(async () => { - const [data, totalResult] = await Promise.all([ - this.db.query.galleryItems.findMany({ - where: eq(galleryItems.category, category), - limit, - offset, - orderBy: asc(galleryItems.title) - }), - this.db.select({ count: count() }) - .from(galleryItems) - .where(eq(galleryItems.category, category)) - ]); - const total = totalResult[0]?.count ?? 0; - const hasMore = offset + limit < total; + return { data, total, hasMore }; + }, "getGalleryItemsByCategory"); + } - return { data, total, hasMore }; - }, 'getGalleryItemsByCategory'); + async createGalleryItem(data: GalleryItem): Promise { + if (!data.title || !data.imageUrl || !data.description || !data.category) { + throw new ValidationError("Title, description, and imageUrl are required"); } - async createGalleryItem(data: GalleryItem): Promise { - if (!data.title || !data.imageUrl || !data.description || !data.category) { - throw new ValidationError('Title, description, and imageUrl are required'); - } + const sanitizedData = { + ...data, + title: sanitizeString(data.title), + category: data.category ? sanitizeString(data.category) : "uncategorized", + createdAt: new Date(), + updatedAt: new Date(), + }; - const sanitizedData = { - ...data, - title: sanitizeString(data.title), - category: data.category ? sanitizeString(data.category) : 'uncategorized', - createdAt: new Date(), - updatedAt: new Date() - }; - - return this.withErrorHandling(async () => { - const [item] = await this.db.insert(galleryItems).values(sanitizedData).returning(); - return item; - }, 'createGalleryItem'); - } + return this.withErrorHandling(async () => { + const [item] = await this.db.insert(galleryItems).values(sanitizedData).returning(); + return item; + }, "createGalleryItem"); + } } diff --git a/server/services/health-service.ts b/server/services/health-service.ts new file mode 100644 index 0000000..c52370c --- /dev/null +++ b/server/services/health-service.ts @@ -0,0 +1,23 @@ +/** + * Health check service — DB-only. No dependency on other domains. + * Keeps the health module microservice-ready. + */ +import dbPool from "../../config/database/db-connection"; +import { logger } from "../../config/logger/logger-config"; + +const { pool } = dbPool; + +/** Minimal type for DB health check; works with both pg.Pool and Neon Pool */ +type PoolLike = { query: (sql: string) => Promise }; + +export async function checkDb(): Promise { + try { + await (pool as PoolLike).query("SELECT 1"); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const code = err instanceof Error && "code" in err ? (err as NodeJS.ErrnoException).code : undefined; + logger.error("Health check: database unreachable", { message, code }); + return false; + } +} diff --git a/server/services/history-service/historyservice.test.ts b/server/services/history-service/historyservice.test.ts index 7765ce2..f61e560 100644 --- a/server/services/history-service/historyservice.test.ts +++ b/server/services/history-service/historyservice.test.ts @@ -1,278 +1,287 @@ // tests/services/history/history-service.test.ts -import { HistoryService } from './index'; -import { historyContent } from '../../../config/database/schema/tables'; -import { NotFoundError, ValidationError } from '../../../middlewares/errors/error-handler'; -import { eq } from 'drizzle-orm'; +import { HistoryService } from "./index"; +import { historyContent } from "../../../config/database/schema/tables"; +import { NotFoundError, ValidationError } from "../../../middlewares/errors/error-handler"; +import { eq } from "drizzle-orm"; // Mock the database and dependencies -jest.mock('../../../config/database/schema'); -jest.mock('../../../server/utils/validations/uuid-validator'); -jest.mock('../../../server/utils/validations/email-validation'); - -describe('HistoryService', () => { - let historyService: HistoryService; - const mockDb = { - query: { - historyContent: { - findFirst: jest.fn(), - findMany: jest.fn(), +jest.mock("../../../config/database/schema"); +jest.mock("../../../server/utils/validations/uuid-validator"); +jest.mock("../../../server/utils/validations/email-validation"); + +describe("HistoryService", () => { + let historyService: HistoryService; + const mockDb = { + query: { + historyContent: { + findFirst: jest.fn(), + findMany: jest.fn(), + }, + }, + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + from: jest.fn(), + }; + + const mockBaseService = { + withErrorHandling: jest.fn(async (fn, methodName) => fn()), + db: mockDb, + }; + + beforeEach(() => { + jest.clearAllMocks(); + historyService = new HistoryService(); + Object.assign(historyService, mockBaseService); + }); + + describe("getHistoryContentById", () => { + it("should return history content when found", async () => { + const mockHistory = { id: "123e4567-e89b-12d3-a456-426614174000", title: "Test History" }; + mockDb.query.historyContent.findFirst.mockResolvedValue(mockHistory); + + const result = await historyService.getHistoryContentById( + "123e4567-e89b-12d3-a456-426614174000" + ); + expect(result).toEqual(mockHistory); + expect(mockDb.query.historyContent.findFirst).toHaveBeenCalledWith({ + where: eq(historyContent.id, "123e4567-e89b-12d3-a456-426614174000"), + with: { + author: { + columns: { + id: true, + username: true, + email: false, }, + }, }, - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - from: jest.fn(), - }; + }); + }); + + it("should return null when history content not found", async () => { + mockDb.query.historyContent.findFirst.mockResolvedValue(null); + const result = await historyService.getHistoryContentById( + "123e4567-e89b-12d3-a456-426614174000" + ); + expect(result).toBeNull(); + }); + + it("should throw ValidationError for invalid ID", async () => { + const validateId = require("../../../server/utils/validations/uuid-validator").validateId; + validateId.mockImplementation(() => { + throw new ValidationError("Invalid ID"); + }); + + await expect(historyService.getHistoryContentById("invalid")).rejects.toThrow( + ValidationError + ); + }); + }); + + describe("getHistoryContentBySlug", () => { + it("should return history content when found by slug", async () => { + const mockHistory = { id: "123e4567-e89b-12d3-a456-426614174000", slug: "test-history" }; + mockDb.query.historyContent.findFirst.mockResolvedValue(mockHistory); + + const result = await historyService.getHistoryContentBySlug("test-history"); + expect(result).toEqual(mockHistory); + }); + + it("should throw ValidationError for empty slug", async () => { + await expect(historyService.getHistoryContentBySlug("")).rejects.toThrow(ValidationError); + }); + }); + + describe("getAllHistoryContent", () => { + it("should return paginated history content", async () => { + const mockData = [{ id: "1" }, { id: "2" }]; + const mockTotal = [{ count: 10 }]; + + mockDb.query.historyContent.findMany.mockResolvedValue(mockData); + mockDb.select.mockReturnValue({ + from: jest.fn().mockResolvedValue(mockTotal), + }); + + const result = await historyService.getAllHistoryContent(2, 0); + expect(result.data).toEqual(mockData); + expect(result.total).toBe(10); + expect(result.hasMore).toBe(true); + }); + + it("should throw ValidationError for invalid limit", async () => { + await expect(historyService.getAllHistoryContent(0)).rejects.toThrow(ValidationError); + await expect(historyService.getAllHistoryContent(101)).rejects.toThrow(ValidationError); + }); - const mockBaseService = { - withErrorHandling: jest.fn(async (fn, methodName) => fn()), - db: mockDb, + it("should throw ValidationError for negative offset", async () => { + await expect(historyService.getAllHistoryContent(10, -1)).rejects.toThrow(ValidationError); + }); + }); + + describe("createHistoryContent", () => { + const validData = { + title: "New History", + content: "Content", + authorId: "user-123", + metaDescription: "meta", + keywords: "keywords", }; - beforeEach(() => { - jest.clearAllMocks(); - historyService = new HistoryService(); - Object.assign(historyService, mockBaseService); + it("should create new history content", async () => { + const mockCreated = { ...validData, id: "new-id" }; + mockDb.insert.mockReturnThis(); + mockDb.values.mockReturnThis(); + mockDb.returning.mockResolvedValue([mockCreated]); + mockDb.query.historyContent.findFirst.mockResolvedValue(null); + + const result = await historyService.createHistoryContent({ + ...validData, + id: "test-id", + slug: "new-history", + imageUrl: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + authorId: validData.authorId, + }); + expect(result).toEqual(mockCreated); }); - describe('getHistoryContentById', () => { - it('should return history content when found', async () => { - const mockHistory = { id: '123e4567-e89b-12d3-a456-426614174000', title: 'Test History' }; - mockDb.query.historyContent.findFirst.mockResolvedValue(mockHistory); - - const result = await historyService.getHistoryContentById('123e4567-e89b-12d3-a456-426614174000'); - expect(result).toEqual(mockHistory); - expect(mockDb.query.historyContent.findFirst).toHaveBeenCalledWith({ - where: eq(historyContent.id, '123e4567-e89b-12d3-a456-426614174000'), - with: { - author: { - columns: { - id: true, - username: true, - email: false, - }, - }, - }, - }); - }); - - it('should return null when history content not found', async () => { - mockDb.query.historyContent.findFirst.mockResolvedValue(null); - const result = await historyService.getHistoryContentById('123e4567-e89b-12d3-a456-426614174000'); - expect(result).toBeNull(); - }); - - it('should throw ValidationError for invalid ID', async () => { - const validateId = require('../../../server/utils/validations/uuid-validator').validateId; - validateId.mockImplementation(() => { - throw new ValidationError('Invalid ID'); - }); - - await expect(historyService.getHistoryContentById('invalid')).rejects.toThrow(ValidationError); - }); + it("should throw ValidationError for missing required fields", async () => { + await expect(historyService.createHistoryContent({} as any)).rejects.toThrow(ValidationError); }); - describe('getHistoryContentBySlug', () => { - it('should return history content when found by slug', async () => { - const mockHistory = { id: '123e4567-e89b-12d3-a456-426614174000', slug: 'test-history' }; - mockDb.query.historyContent.findFirst.mockResolvedValue(mockHistory); + it("should append timestamp to slug if duplicate exists", async () => { + const mockExisting = { id: "existing" }; + mockDb.query.historyContent.findFirst + .mockResolvedValueOnce(mockExisting) + .mockResolvedValueOnce(null); + + const mockCreated = { ...validData, id: "new-id", slug: "new-history-123456" }; + mockDb.returning.mockResolvedValue([mockCreated]); + + const result = await historyService.createHistoryContent({ + ...validData, + slug: "new-history", + id: "test-id", + imageUrl: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + authorId: validData.authorId, + }); + expect(result.slug).toMatch(/new-history-\d+/); + }); + }); + + // Update this in your test file (history-service.test.ts) + + describe("updateHistoryContent", () => { + const validUpdate = { title: "Updated Title" }; + const validId = "123e4567-e89b-12d3-a456-426614174000"; // Valid UUID format + const invalidId = "invalid-id"; - const result = await historyService.getHistoryContentBySlug('test-history'); - expect(result).toEqual(mockHistory); - }); + // Mock the validateId function properly + const mockValidateId = jest.spyOn( + require("../../../server/utils/validations/uuid-validator"), + "validateId" + ); - it('should throw ValidationError for empty slug', async () => { - await expect(historyService.getHistoryContentBySlug('')).rejects.toThrow(ValidationError); - }); + beforeEach(() => { + // Reset mocks before each test + mockValidateId.mockImplementation(() => {}); // Default mock does nothing }); - describe('getAllHistoryContent', () => { - it('should return paginated history content', async () => { - const mockData = [{ id: '1' }, { id: '2' }]; - const mockTotal = [{ count: 10 }]; - - mockDb.query.historyContent.findMany.mockResolvedValue(mockData); - mockDb.select.mockReturnValue({ - from: jest.fn().mockResolvedValue(mockTotal), - }); - - const result = await historyService.getAllHistoryContent(2, 0); - expect(result.data).toEqual(mockData); - expect(result.total).toBe(10); - expect(result.hasMore).toBe(true); - }); - - it('should throw ValidationError for invalid limit', async () => { - await expect(historyService.getAllHistoryContent(0)).rejects.toThrow(ValidationError); - await expect(historyService.getAllHistoryContent(101)).rejects.toThrow(ValidationError); - }); - - it('should throw ValidationError for negative offset', async () => { - await expect(historyService.getAllHistoryContent(10, -1)).rejects.toThrow(ValidationError); - }); + it("should update existing history content", async () => { + // Setup + const mockExisting = { + id: validId, + title: "Original", + }; + const mockUpdated = { + ...mockExisting, + ...validUpdate, + }; + + mockDb.query.historyContent.findFirst.mockResolvedValue(mockExisting); + mockDb.update.mockReturnThis(); + mockDb.set.mockReturnThis(); + mockDb.where.mockReturnThis(); + mockDb.returning.mockResolvedValue([mockUpdated]); + + // Test + const result = await historyService.updateHistoryContent(validId, validUpdate); + + // Verify + expect(result).toEqual(mockUpdated); + expect(mockValidateId).toHaveBeenCalledWith(validId, "history content id"); + // More detailed verification }); - describe('createHistoryContent', () => { - const validData = { - title: 'New History', - content: 'Content', - authorId: 'user-123', - metaDescription: 'meta', - keywords: 'keywords', - }; - - it('should create new history content', async () => { - const mockCreated = { ...validData, id: 'new-id' }; - mockDb.insert.mockReturnThis(); - mockDb.values.mockReturnThis(); - mockDb.returning.mockResolvedValue([mockCreated]); - mockDb.query.historyContent.findFirst.mockResolvedValue(null); - - const result = await historyService.createHistoryContent({ - ...validData, - id: 'test-id', - slug: 'new-history', - imageUrl: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - authorId: validData.authorId, - }); - expect(result).toEqual(mockCreated); - }); - - it('should throw ValidationError for missing required fields', async () => { - await expect(historyService.createHistoryContent({} as any)).rejects.toThrow(ValidationError); - }); - - it('should append timestamp to slug if duplicate exists', async () => { - const mockExisting = { id: 'existing' }; - mockDb.query.historyContent.findFirst.mockResolvedValueOnce(mockExisting).mockResolvedValueOnce(null); - - const mockCreated = { ...validData, id: 'new-id', slug: 'new-history-123456' }; - mockDb.returning.mockResolvedValue([mockCreated]); - - const result = await historyService.createHistoryContent({ - ...validData, - slug: 'new-history', - id: 'test-id', - imageUrl: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - authorId: validData.authorId, - }); - expect(result.slug).toMatch(/new-history-\d+/); - }); + it("should throw NotFoundError for non-existent content", async () => { + mockDb.query.historyContent.findFirst.mockResolvedValue(null); + await expect(historyService.updateHistoryContent(validId, validUpdate)).rejects.toThrow( + NotFoundError + ); }); - // Update this in your test file (history-service.test.ts) - - describe('updateHistoryContent', () => { - const validUpdate = { title: 'Updated Title' }; - const validId = '123e4567-e89b-12d3-a456-426614174000'; // Valid UUID format - const invalidId = 'invalid-id'; - - // Mock the validateId function properly - const mockValidateId = jest.spyOn( - require('../../../server/utils/validations/uuid-validator'), - 'validateId' - ); - - beforeEach(() => { - // Reset mocks before each test - mockValidateId.mockImplementation(() => { }); // Default mock does nothing - }); - - it('should update existing history content', async () => { - // Setup - const mockExisting = { - id: validId, - title: 'Original' - }; - const mockUpdated = { - ...mockExisting, - ...validUpdate - }; - - mockDb.query.historyContent.findFirst.mockResolvedValue(mockExisting); - mockDb.update.mockReturnThis(); - mockDb.set.mockReturnThis(); - mockDb.where.mockReturnThis(); - mockDb.returning.mockResolvedValue([mockUpdated]); - - // Test - const result = await historyService.updateHistoryContent(validId, validUpdate); - - // Verify - expect(result).toEqual(mockUpdated); - expect(mockValidateId).toHaveBeenCalledWith(validId, 'history content id'); - // More detailed verification - - }); - - it('should throw NotFoundError for non-existent content', async () => { - mockDb.query.historyContent.findFirst.mockResolvedValue(null); - await expect(historyService.updateHistoryContent(validId, validUpdate)) - .rejects.toThrow(NotFoundError); - }); - - it('should throw ValidationError for empty update data', async () => { - await expect(historyService.updateHistoryContent(validId, {})) - .rejects.toThrow(ValidationError); - }); - - // it('should throw ValidationError for invalid ID', async () => { - // const invalidId = 'invalid-id'; - // mockValidateId.mockImplementation(() => { - // throw new ValidationError('Invalid ID'); - // }); - - // await expect(historyService.updateHistoryContent(invalidId, validUpdate)) - // .rejects.toThrow(ValidationError); - // }); - it('should throw ValidationError for invalid ID', async () => { - const invalidId = 'invalid-id'; - const validUpdate = { title: 'Updated Title' }; - - await expect(historyService.updateHistoryContent(invalidId, validUpdate)) - .rejects.toThrow(ValidationError); - - // More detailed verification - try { - await historyService.updateHistoryContent(invalidId, validUpdate); - fail('Should have thrown'); - } catch (error: any) { - expect(error).toBeInstanceOf(ValidationError); - expect(error.message).toContain('Invalid ID'); - } - }); + it("should throw ValidationError for empty update data", async () => { + await expect(historyService.updateHistoryContent(validId, {})).rejects.toThrow( + ValidationError + ); }); - describe('deleteHistoryContent', () => { - const validId = '123e4567-e89b-12d3-a456-426614174000'; - - it('should throw NotFoundError for non-existent content', async () => { - mockDb.query.historyContent.findFirst.mockResolvedValue(null); - - // Option 1: Simple check - await expect(historyService.deleteHistoryContent(validId)) - .rejects.toThrow(NotFoundError); - - // Option 2: Detailed check - try { - await historyService.deleteHistoryContent(validId); - fail('Should have thrown'); - } catch (error: any) { - expect(error).toBeInstanceOf(NotFoundError); - expect(error.message).toContain('History content'); - expect(error.message).toContain(validId); - } - }); + // it('should throw ValidationError for invalid ID', async () => { + // const invalidId = 'invalid-id'; + // mockValidateId.mockImplementation(() => { + // throw new ValidationError('Invalid ID'); + // }); + + // await expect(historyService.updateHistoryContent(invalidId, validUpdate)) + // .rejects.toThrow(ValidationError); + // }); + it("should throw ValidationError for invalid ID", async () => { + const invalidId = "invalid-id"; + const validUpdate = { title: "Updated Title" }; + + await expect(historyService.updateHistoryContent(invalidId, validUpdate)).rejects.toThrow( + ValidationError + ); + + // More detailed verification + try { + await historyService.updateHistoryContent(invalidId, validUpdate); + fail("Should have thrown"); + } catch (error: any) { + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toContain("Invalid ID"); + } + }); + }); + + describe("deleteHistoryContent", () => { + const validId = "123e4567-e89b-12d3-a456-426614174000"; + + it("should throw NotFoundError for non-existent content", async () => { + mockDb.query.historyContent.findFirst.mockResolvedValue(null); + + // Option 1: Simple check + await expect(historyService.deleteHistoryContent(validId)).rejects.toThrow(NotFoundError); + + // Option 2: Detailed check + try { + await historyService.deleteHistoryContent(validId); + fail("Should have thrown"); + } catch (error: any) { + expect(error).toBeInstanceOf(NotFoundError); + expect(error.message).toContain("History content"); + expect(error.message).toContain(validId); + } }); + }); }); diff --git a/server/services/history-service/index.ts b/server/services/history-service/index.ts index 3d0376b..bf7d223 100644 --- a/server/services/history-service/index.ts +++ b/server/services/history-service/index.ts @@ -9,205 +9,207 @@ import { BaseService } from "../base-service"; import { historyContent } from "../../../config/database/schema/tables"; export class HistoryService extends BaseService { + async getHistoryContentById(id: string): Promise { + validateId(id, "history content id"); + + console.log("\n\n 💥💥💥💥💥getHistoryContentById id: ", id); + + return this.withErrorHandling(async () => { + const result = await this.db.query.historyContent.findFirst({ + where: eq(historyContent.id, id), + with: { + author: { + columns: { + id: true, + username: true, + email: false, + }, + }, + }, + }); + console.log("\n\n 💥💥💥💥💥getHistoryContentById result: ", result); + return result || null; + }, "getHistoryContentById"); + } + + async getHistoryContentBySlug(slug: string): Promise { + if (!slug || typeof slug !== "string") { + throw new ValidationError("Slug must be a non-empty string"); + } + + const sanitizedSlug = sanitizeString(slug.toLowerCase()); + + return this.withErrorHandling(async () => { + const result = await this.db.query.historyContent.findFirst({ + where: eq(historyContent.slug, sanitizedSlug), + with: { + author: { + columns: { + id: true, + username: true, + email: false, + }, + }, + }, + }); + return result || null; + }, "getHistoryContentBySlug"); + } + + async getAllHistoryContent( + limit: number = 50, + offset: number = 0 + ): Promise<{ + data: HistoryContent[]; + total: number; + hasMore: boolean; + }> { + if (limit < 1 || limit > 100) { + throw new ValidationError("Limit must be between 1 and 100"); + } - async getHistoryContentById(id: string): Promise { - validateId(id, 'history content id'); - - console.log("\n\n 💥💥💥💥💥getHistoryContentById id: ", id); - - return this.withErrorHandling(async () => { - const result = await this.db.query.historyContent.findFirst({ - where: eq(historyContent.id, id), - with: { - author: { - columns: { - id: true, - username: true, - email: false - } - } - } - }); - console.log("\n\n 💥💥💥💥💥getHistoryContentById result: ", result); - return result || null; - }, 'getHistoryContentById'); + if (offset < 0) { + throw new ValidationError("Offset must be non-negative"); } - async getHistoryContentBySlug(slug: string): Promise { + return this.withErrorHandling(async () => { + const [data, totalResult] = await Promise.all([ + this.db.query.historyContent.findMany({ + limit, + offset, + orderBy: desc(historyContent.createdAt), + with: { + author: { + columns: { + id: true, + username: true, + email: false, + }, + }, + }, + }), + this.db.select({ count: count() }).from(historyContent), + ]); + + const total = totalResult[0]?.count || 0; + const hasMore = offset + limit < total; + + return { data, total, hasMore }; + }, "getAllHistoryContent"); + } + + async createHistoryContent(data: HistoryContent): Promise { + if (!data.title || !data.content || !data.authorId || !data.metaDescription || !data.keywords) { + throw new ValidationError("Title, content, and authorId are required"); + } - if (!slug || typeof slug !== 'string') { - throw new ValidationError('Slug must be a non-empty string'); + const sanitizedData = { + ...data, + title: sanitizeString(data.title), + slug: data.slug + ? sanitizeString(data.slug.toLowerCase()) + : sanitizeString(slugify(data.title)), + createdAt: new Date(), + updatedAt: new Date(), + }; + + try { + return this.withErrorHandling(async () => { + // Check for duplicate slug + const existingContent = await this.getHistoryContentBySlug(sanitizedData.slug); + if (existingContent) { + sanitizedData.slug = `${sanitizedData.slug}-${Date.now()}`; } - const sanitizedSlug = sanitizeString(slug.toLowerCase()); - - return this.withErrorHandling(async () => { - const result = await this.db.query.historyContent.findFirst({ - where: eq(historyContent.slug, sanitizedSlug), - with: { - author: { - columns: { - id: true, - username: true, - email: false - } - } - } - }); - return result || null; - }, 'getHistoryContentBySlug'); + const [content] = await this.db.insert(historyContent).values(sanitizedData).returning(); + return content; + }, "createHistoryContent"); + } catch (error) { + console.error("\n\n 💥💥💥💥💥Error in createHistoryContent:", error); + throw new Error("Failed to create history content"); } + } - async getAllHistoryContent(limit: number = 50, offset: number = 0): Promise<{ - data: HistoryContent[]; - total: number; - hasMore: boolean; - }> { + async updateHistoryContent(id: string, data: Partial): Promise { + try { + validateId(id, "history content id"); - if (limit < 1 || limit > 100) { - throw new ValidationError('Limit must be between 1 and 100'); - } + if (Object.keys(data).length === 0) { + throw new ValidationError("No data provided for update"); + } - if (offset < 0) { - throw new ValidationError('Offset must be non-negative'); - } + const sanitizedData: Partial = { + ...data, + updatedAt: new Date(), + }; - return this.withErrorHandling(async () => { - const [data, totalResult] = await Promise.all([ - this.db.query.historyContent.findMany({ - limit, - offset, - orderBy: desc(historyContent.createdAt), - with: { - author: { - columns: { - id: true, - username: true, - email: false - } - } - } - }), - this.db.select({ count: count() }).from(historyContent) - ]); - - const total = totalResult[0]?.count || 0; - const hasMore = offset + limit < total; - - return { data, total, hasMore }; - }, 'getAllHistoryContent'); - } + if (data.slug) { + sanitizedData.slug = sanitizeString(data.slug.toLowerCase()); + } - async createHistoryContent(data: HistoryContent): Promise { - if (!data.title || !data.content || !data.authorId || !data.metaDescription || !data.keywords) { - throw new ValidationError('Title, content, and authorId are required'); - } - - const sanitizedData = { - ...data, - title: sanitizeString(data.title), - slug: data.slug ? sanitizeString(data.slug.toLowerCase()) : - sanitizeString(slugify(data.title)), - createdAt: new Date(), - updatedAt: new Date() - }; - - try { - return this.withErrorHandling(async () => { - // Check for duplicate slug - const existingContent = await this.getHistoryContentBySlug(sanitizedData.slug); - if (existingContent) { - sanitizedData.slug = `${sanitizedData.slug}-${Date.now()}`; - } - - const [content] = await this.db.insert(historyContent).values(sanitizedData).returning(); - return content; - }, 'createHistoryContent'); - } catch (error) { - console.error('\n\n 💥💥💥💥💥Error in createHistoryContent:', error); - throw new Error('Failed to create history content'); - } - } + if (data.title) { + sanitizedData.title = sanitizeString(data.title); - async updateHistoryContent(id: string, data: Partial): Promise { - try { - validateId(id, 'history content id'); - - if (Object.keys(data).length === 0) { - throw new ValidationError('No data provided for update'); - } - - const sanitizedData: Partial = { - ...data, - updatedAt: new Date() - }; - - if (data.slug) { - sanitizedData.slug = sanitizeString(data.slug.toLowerCase()); - } - - if (data.title) { - sanitizedData.title = sanitizeString(data.title); - - if (!data.slug) { - sanitizedData.slug = sanitizeString(slugify(data?.title)); - } - } - - // check if this history exists first - const history = await this.getHistoryContentById(id); - if (!history) { - throw new NotFoundError('History content', id); - } - - - return this.withErrorHandling(async () => { - const [updatedContent] = await this.db - .update(historyContent) - .set(sanitizedData) - .where(eq(historyContent.id, id)) - .returning(); - - if (!updatedContent) { - throw new NotFoundError('History content', id); - } - - return updatedContent; - }, 'updateHistoryContent', - { preserveErrors: [ValidationError, NotFoundError] }); // 👈 Preserve specific error types - } catch (error) { - console.error('\n\n 💥💥💥💥💥Error in updateHistoryContent:', error); - throw new Error('Failed to update history content'); + if (!data.slug) { + sanitizedData.slug = sanitizeString(slugify(data?.title)); } + } + + // check if this history exists first + const history = await this.getHistoryContentById(id); + if (!history) { + throw new NotFoundError("History content", id); + } + + return this.withErrorHandling( + async () => { + const [updatedContent] = await this.db + .update(historyContent) + .set(sanitizedData) + .where(eq(historyContent.id, id)) + .returning(); + + if (!updatedContent) { + throw new NotFoundError("History content", id); + } + + return updatedContent; + }, + "updateHistoryContent", + { preserveErrors: [ValidationError, NotFoundError] } + ); // 👈 Preserve specific error types + } catch (error) { + console.error("\n\n 💥💥💥💥💥Error in updateHistoryContent:", error); + throw new Error("Failed to update history content"); } - - // delete an history - async deleteHistoryContent(id: string): Promise { - try { - validateId(id, 'history content id'); - - return this.withErrorHandling( - async () => { - validateId(id, 'history content id'); - - const history = await this.getHistoryContentById(id); - if (!history) { - throw new NotFoundError('History content', id); - } - - const result = await this.db.delete(historyContent) - .where(eq(historyContent.id, id)); - - if (result.rowCount === 0) { - throw new NotFoundError('History content', id); - } - }, - 'deleteHistoryContent', - { preserveErrors: [NotFoundError] } // 👈 This preserves NotFoundError - ); - } catch (error) { - console.error('\n\n 💥💥💥💥💥Error in deleteHistoryContent:', error); - throw new Error('Failed to delete history content'); - } + } + + // delete an history + async deleteHistoryContent(id: string): Promise { + try { + validateId(id, "history content id"); + + return this.withErrorHandling( + async () => { + validateId(id, "history content id"); + + const history = await this.getHistoryContentById(id); + if (!history) { + throw new NotFoundError("History content", id); + } + + const result = await this.db.delete(historyContent).where(eq(historyContent.id, id)); + + if (result.rowCount === 0) { + throw new NotFoundError("History content", id); + } + }, + "deleteHistoryContent", + { preserveErrors: [NotFoundError] } // 👈 This preserves NotFoundError + ); + } catch (error) { + console.error("\n\n 💥💥💥💥💥Error in deleteHistoryContent:", error); + throw new Error("Failed to delete history content"); } + } } diff --git a/server/services/index.ts b/server/services/index.ts index 800321e..9336ed4 100644 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -20,22 +20,22 @@ export const postLikeService = new PostLikeService(); // Export individual class services for direct access export { - UserService, - HistoryService, - GalleryService, - BookingService, - ForumService, - ContactService, - SessionService + UserService, + HistoryService, + GalleryService, + BookingService, + ForumService, + ContactService, + SessionService, }; // Export types for better type safety export type { - User, - HistoryContent, - GalleryItem, - Booking, - Post, - Comment, - ContactMessage + User, + HistoryContent, + GalleryItem, + Booking, + Post, + Comment, + ContactMessage, } from "../../config/database/schema/schema-types"; diff --git a/server/services/post_like-service.ts b/server/services/post_like-service.ts index 887ba45..3577ec1 100644 --- a/server/services/post_like-service.ts +++ b/server/services/post_like-service.ts @@ -6,47 +6,40 @@ import { validateId } from "../../server/utils/validations/uuid-validator"; import { postLikes } from "../../config/database/schema/tables"; export class PostLikeService extends BaseService { - - async postLike(data: PostLike) { - try { - validateId(data?.userId, 'user id'); - validateId(data?.postId, 'post id'); - - - return this.withErrorHandling(async () => { - const result = await this.db.insert(postLikes).values(data).returning(); - return result || null; - }, 'getBookingById'); - } catch (error) { - console.error(error); - throw new Error(` error creating like for post id : ${data?.postId} created by user id : ${data?.userId}`) - } + async postLike(data: PostLike) { + try { + validateId(data?.userId, "user id"); + validateId(data?.postId, "post id"); + + return this.withErrorHandling(async () => { + const result = await this.db.insert(postLikes).values(data).returning(); + return result || null; + }, "getBookingById"); + } catch (error) { + console.error(error); + throw new Error( + ` error creating like for post id : ${data?.postId} created by user id : ${data?.userId}` + ); } - - - // update the like to becone unlike - async unlikePost(data: PostLike) { - try { - validateId(data?.userId, 'user id'); - validateId(data?.postId, 'post id'); - return this.withErrorHandling(async () => { - const result = await this.db - .delete(postLikes) - .where( - and( - eq(postLikes.userId, data.userId), - eq(postLikes.postId, data.postId) - ) - ) - .returning(); - return result || null; - }, "unlikePost"); - } catch (error) { - console.error(error); - throw new Error(` error deleting like for post id : ${data?.postId} created by user id : ${data?.userId}`) - } - + } + + // update the like to becone unlike + async unlikePost(data: PostLike) { + try { + validateId(data?.userId, "user id"); + validateId(data?.postId, "post id"); + return this.withErrorHandling(async () => { + const result = await this.db + .delete(postLikes) + .where(and(eq(postLikes.userId, data.userId), eq(postLikes.postId, data.postId))) + .returning(); + return result || null; + }, "unlikePost"); + } catch (error) { + console.error(error); + throw new Error( + ` error deleting like for post id : ${data?.postId} created by user id : ${data?.userId}` + ); } - - + } } diff --git a/server/services/session-service.ts b/server/services/session-service.ts index f55b62f..678c536 100644 --- a/server/services/session-service.ts +++ b/server/services/session-service.ts @@ -4,22 +4,22 @@ import session from "express-session"; import { BaseService } from "./base-service"; export class SessionService extends BaseService { - private sessionStore: session.Store; + private sessionStore: session.Store; - constructor() { - super(); - const PostgresSessionStore = connectPg(session); + constructor() { + super(); + const PostgresSessionStore = connectPg(session); - this.sessionStore = new PostgresSessionStore({ - pool: this.connectionPool, - createTableIfMissing: true, - errorLog: (error: Error) => { - console.error('\n\n Session store error:', error); - } - }); - } + this.sessionStore = new PostgresSessionStore({ + pool: this.connectionPool, + createTableIfMissing: true, + errorLog: (error: Error) => { + console.error("\n\n Session store error:", error); + }, + }); + } - getSessionStore(): session.Store { - return this.sessionStore; - } + getSessionStore(): session.Store { + return this.sessionStore; + } } diff --git a/server/services/user-service.ts b/server/services/user-service.ts index 3218ae2..b8011bb 100644 --- a/server/services/user-service.ts +++ b/server/services/user-service.ts @@ -10,241 +10,235 @@ import schema from "../../config/database/schema/tables"; const { users } = schema; export class UserService extends BaseService { - - async getUser(id: string): Promise { - const validatedId = validateId(id, 'user id'); - - return this.withErrorHandling(async () => { - const result = await this.db.query.users.findFirst({ - where: eq(users.id, validatedId) - }); - return result || null; - }, 'getUser'); + async getUser(id: string): Promise { + const validatedId = validateId(id, "user id"); + + return this.withErrorHandling(async () => { + const result = await this.db.query.users.findFirst({ + where: eq(users.id, validatedId), + }); + return result || null; + }, "getUser"); + } + + // delete user by Email + async deleteUserByEmail(email: string): Promise { + validateEmail(email); + + // check if the user exists + const user = await this.getUserByEmail(email); + if (!user) { + throw new NotFoundError("User", email); } - // delete user by Email - async deleteUserByEmail(email: string): Promise { - validateEmail(email); - - // check if the user exists - const user = await this.getUserByEmail(email); - if (!user) { - throw new NotFoundError('User', email); - } - - return this.withErrorHandling(async () => { - const result = await this.db.delete(users).where(eq(users.email, email)); - if (result.rowCount === 0) { - throw new NotFoundError('User', email); - } - return; - }, 'deleteUserByEmail'); - + return this.withErrorHandling(async () => { + const result = await this.db.delete(users).where(eq(users.email, email)); + if (result.rowCount === 0) { + throw new NotFoundError("User", email); + } + return; + }, "deleteUserByEmail"); + } + + // delete user by id + async deleteUserById(id: string): Promise { + validateId(id, "user id"); + + // check if the user exists + const user = await this.getUser(id); + if (!user) { + throw new NotFoundError("User", id); } - // delete user by id - async deleteUserById(id: string): Promise { - validateId(id, 'user id'); - - // check if the user exists - const user = await this.getUser(id); - if (!user) { - throw new NotFoundError('User', id); - } + return this.deleteUserByEmail(user.email); + } - return this.deleteUserByEmail(user.email); + async getUserByUsername(username: string): Promise { + if (!username) { + throw new ValidationError("Username must be a non-empty string"); } + const sanitizedUsername = sanitizeString(username.toLowerCase()); - async getUserByUsername(username: string): Promise { - if (!username) { - throw new ValidationError('Username must be a non-empty string'); - } + return this.withErrorHandling(async () => { + const result = await this.db.query.users.findFirst({ + where: eq(users.username, sanitizedUsername), + }); + return result || null; + }, "getUserByUsername"); + } - const sanitizedUsername = sanitizeString(username.toLowerCase()); - - return this.withErrorHandling(async () => { - const result = await this.db.query.users.findFirst({ - where: eq(users.username, sanitizedUsername) - }); - return result || null; - }, 'getUserByUsername'); - } + async getUserByEmail(email: string): Promise { + validateEmail(email); - async getUserByEmail(email: string): Promise { - validateEmail(email); + const sanitizedEmail = sanitizeString(email.toLowerCase()); - const sanitizedEmail = sanitizeString(email.toLowerCase()); + return this.withErrorHandling(async () => { + const result = await this.db.query.users.findFirst({ + where: eq(users.email, sanitizedEmail), + }); + return result || null; + }, "getUserByEmail"); + } - return this.withErrorHandling(async () => { - const result = await this.db.query.users.findFirst({ - where: eq(users.email, sanitizedEmail) - }); - return result || null; - }, 'getUserByEmail'); + async createUser(userData: User): Promise { + if (!userData.username || !userData.email) { + throw new ValidationError("Username and email are required"); } - async createUser(userData: User): Promise { - if (!userData.username || !userData.email) { - throw new ValidationError('Username and email are required'); - } - - if (!userData.password) { - throw new ValidationError('Password is required'); - } - validateEmail(userData.email); - - const sanitizedData = { - ...userData, - username: sanitizeString(userData.username.toLowerCase()), - email: sanitizeString(userData.email.toLowerCase()), - }; - - return this.withErrorHandling(async () => { - // Check for existing user - const existingUser = await this.getUserByEmail(sanitizedData.email) || - await this.getUserByUsername(sanitizedData.username); - - if (existingUser) { - throw new ValidationError('User with this email or username already exists'); - } - - // Ensure all required fields are present and generate an id if missing - const userToInsert = { - ...sanitizedData, - id: sanitizedData.id ?? generateUUID(), - createdAt: sanitizedData.createdAt ?? new Date(), - updatedAt: sanitizedData.updatedAt ?? new Date(), - password: sanitizedData.password ?? '', - fullName: sanitizedData.fullName, - userType: sanitizedData.userType, - profileImage: sanitizedData.profileImage ?? null, - bio: sanitizedData.bio ?? null - }; - const [user] = await this.db.insert(users).values(userToInsert).returning(); - return user; - }, 'createUser'); + if (!userData.password) { + throw new ValidationError("Password is required"); + } + validateEmail(userData.email); + + const sanitizedData = { + ...userData, + username: sanitizeString(userData.username.toLowerCase()), + email: sanitizeString(userData.email.toLowerCase()), + }; + + return this.withErrorHandling(async () => { + // Check for existing user + const existingUser = + (await this.getUserByEmail(sanitizedData.email)) || + (await this.getUserByUsername(sanitizedData.username)); + + if (existingUser) { + throw new ValidationError("User with this email or username already exists"); + } + + // Ensure all required fields are present and generate an id if missing + const userToInsert = { + ...sanitizedData, + id: sanitizedData.id ?? generateUUID(), + createdAt: sanitizedData.createdAt ?? new Date(), + updatedAt: sanitizedData.updatedAt ?? new Date(), + password: sanitizedData.password ?? "", + fullName: sanitizedData.fullName, + userType: sanitizedData.userType, + profileImage: sanitizedData.profileImage ?? null, + bio: sanitizedData.bio ?? null, + }; + const [user] = await this.db.insert(users).values(userToInsert).returning(); + return user; + }, "createUser"); + } + + async updateUser(id: string, userData: Partial): Promise { + const validatedId = validateId(id, "user id"); + + if (Object.keys(userData).length === 0) { + throw new ValidationError("No data provided for update"); } - async updateUser(id: string, userData: Partial): Promise { - const validatedId = validateId(id, 'user id'); - - if (Object.keys(userData).length === 0) { - throw new ValidationError('No data provided for update'); - } - - const sanitizedData: Partial = { - ...userData, - }; - - if (userData.email) { - validateEmail(userData.email); - sanitizedData.email = sanitizeString(userData.email.toLowerCase()); - } - - if (userData.username) { - sanitizedData.username = sanitizeString(userData.username.toLowerCase()); - } - - return this.withErrorHandling(async () => { - const [updatedUser] = await this.db - .update(users) - .set(sanitizedData) - .where(eq(users.id, validatedId)) - .returning(); + const sanitizedData: Partial = { + ...userData, + }; - if (!updatedUser) { - throw new NotFoundError('User', validatedId); - } + if (userData.email) { + validateEmail(userData.email); + sanitizedData.email = sanitizeString(userData.email.toLowerCase()); + } - return updatedUser; - }, 'updateUser'); + if (userData.username) { + sanitizedData.username = sanitizeString(userData.username.toLowerCase()); } - // get all users - async getAllUsers( - limit: number = 20, - offset: number = 0, - role?: "visitor" | "attendant" | "admin" | undefined - ): Promise<{ data: User[]; total: number; hasMore: boolean }> { - try { - const whereClause = role ? eq(users.userType, role) : sql`true`; - return this.withErrorHandling(async () => { - - const [data, totalResult] = await Promise.all([ - this.db.query.users.findMany({ - where: whereClause, - limit, - offset, - orderBy: desc(users.createdAt) - }), - this.db.select({ count: count() }) - .from(users) - .where(whereClause) - ]); - - const total = totalResult[0]?.count || 0; - const hasMore = offset + limit < total; - console.log("\n\n total: ", total, data) - return { data, total, hasMore }; - }, 'getAllUsers'); - } catch (error) { - console.error('\n\n 💥💥💥💥💥Error in getAllUsers:', error); - throw error; - } + return this.withErrorHandling(async () => { + const [updatedUser] = await this.db + .update(users) + .set(sanitizedData) + .where(eq(users.id, validatedId)) + .returning(); + + if (!updatedUser) { + throw new NotFoundError("User", validatedId); + } + + return updatedUser; + }, "updateUser"); + } + + // get all users + async getAllUsers( + limit: number = 20, + offset: number = 0, + role?: "visitor" | "attendant" | "admin" | undefined + ): Promise<{ data: User[]; total: number; hasMore: boolean }> { + try { + const whereClause = role ? eq(users.userType, role) : sql`true`; + return this.withErrorHandling(async () => { + const [data, totalResult] = await Promise.all([ + this.db.query.users.findMany({ + where: whereClause, + limit, + offset, + orderBy: desc(users.createdAt), + }), + this.db.select({ count: count() }).from(users).where(whereClause), + ]); + + const total = totalResult[0]?.count || 0; + const hasMore = offset + limit < total; + console.log("\n\n total: ", total, data); + return { data, total, hasMore }; + }, "getAllUsers"); + } catch (error) { + console.error("\n\n 💥💥💥💥💥Error in getAllUsers:", error); + throw error; } - // async getAllUsers( - // limit: number = 20, - // offset: number = 0, - // role?: "visitor" | "attendant" | "admin" | undefined - // ): Promise<{ data: User[]; total: number; hasMore: boolean }> { - // try { - // console.log('\n\n=== START DEBUGGING getAllUsers ==='); - // console.log('Input parameters:', { limit, offset, role }); - - // return this.withErrorHandling(async () => { - // // Debug: Show the generated where clause - // const whereClause = role ? eq(users.userType, role) : sql`true`; - // console.log('Generated where clause:', whereClause); - - // // Debug: Show the actual SQL being generated - // const dataQuery = this.db.query.users.findMany({ - // where: whereClause, - // limit, - // offset, - // orderBy: desc(users.createdAt) - // }); - // console.log('Data query SQL:', dataQuery.toSQL()); - - // const countQuery = this.db.select({ count: count() }) - // .from(users) - // .where(whereClause); - // console.log('Count query SQL:', countQuery.toSQL()); - - // const [data, totalResult] = await Promise.all([ - // dataQuery, - // countQuery - // ]); - - // console.log('Raw data results:', data); - // console.log('Raw count results:', totalResult); - - // const total = totalResult[0]?.count || 0; - // const hasMore = offset + limit < total; - - // console.log('Final response:', { data, total, hasMore }); - // console.log('=== END DEBUGGING ===\n\n'); - - // return { data, total, hasMore }; - // }, 'getAllUsers'); - // } catch (error) { - // console.error('\n\n 💥 Error in getAllUsers:', error); - // throw error; - // } - // } + } + // async getAllUsers( + // limit: number = 20, + // offset: number = 0, + // role?: "visitor" | "attendant" | "admin" | undefined + // ): Promise<{ data: User[]; total: number; hasMore: boolean }> { + // try { + // console.log('\n\n=== START DEBUGGING getAllUsers ==='); + // console.log('Input parameters:', { limit, offset, role }); + + // return this.withErrorHandling(async () => { + // // Debug: Show the generated where clause + // const whereClause = role ? eq(users.userType, role) : sql`true`; + // console.log('Generated where clause:', whereClause); + + // // Debug: Show the actual SQL being generated + // const dataQuery = this.db.query.users.findMany({ + // where: whereClause, + // limit, + // offset, + // orderBy: desc(users.createdAt) + // }); + // console.log('Data query SQL:', dataQuery.toSQL()); + + // const countQuery = this.db.select({ count: count() }) + // .from(users) + // .where(whereClause); + // console.log('Count query SQL:', countQuery.toSQL()); + + // const [data, totalResult] = await Promise.all([ + // dataQuery, + // countQuery + // ]); + + // console.log('Raw data results:', data); + // console.log('Raw count results:', totalResult); + + // const total = totalResult[0]?.count || 0; + // const hasMore = offset + limit < total; + + // console.log('Final response:', { data, total, hasMore }); + // console.log('=== END DEBUGGING ===\n\n'); + + // return { data, total, hasMore }; + // }, 'getAllUsers'); + // } catch (error) { + // console.error('\n\n 💥 Error in getAllUsers:', error); + // throw error; + // } + // } } // function count() { // // Returns a SQL fragment for COUNT(*) // return sql`count(*)`; // } - diff --git a/server/types/api-response.ts b/server/types/api-response.ts new file mode 100644 index 0000000..b9644b8 --- /dev/null +++ b/server/types/api-response.ts @@ -0,0 +1,56 @@ +/** + * Standard REST API response types (production-ready). + * All successful responses use a consistent envelope; errors use StandardErrorResponse from error-response. + */ + +/** Metadata for list responses (pagination, etc.) */ +export interface ApiResponseMeta { + /** Current page (1-based) when paginated */ + page?: number; + /** Items per page */ + pageSize?: number; + /** Total number of items */ + total?: number; + /** ISO timestamp of the response */ + timestamp: string; +} + +/** Standard success envelope for single-resource and action responses */ +export interface ApiSuccessResponse { + success: true; + data: T; + /** Optional metadata (e.g. pagination, version) */ + meta?: ApiResponseMeta; +} + +/** Health check component status */ +export interface HealthCheckComponent { + status: "up" | "down"; + /** Optional message or latency hint */ + message?: string; +} + +/** Health check response body (used by GET /health) */ +export interface HealthCheckData { + status: "healthy" | "unhealthy"; + timestamp: string; + version: string; + checks: Record; +} + +/** Helper to build a success response object */ +export function createSuccessResponse( + data: T, + meta?: Partial +): ApiSuccessResponse { + return { + success: true, + data, + ...(meta && { + meta: { + timestamp: new Date().toISOString(), + ...meta, + }, + }), + }; +} diff --git a/server/utils/__tests__/email-validation.test.ts b/server/utils/__tests__/email-validation.test.ts new file mode 100644 index 0000000..88d34bd --- /dev/null +++ b/server/utils/__tests__/email-validation.test.ts @@ -0,0 +1,73 @@ +/** + * Unit tests for server/utils/validations/email-validation.ts. + * Covers validateEmail, sanitizeString, and slugify. + */ +import { validateEmail, sanitizeString, slugify } from "../validations/email-validation"; +import { ValidationError } from "../../../middlewares/errors/error-handler"; + +describe("email-validation", () => { + describe("validateEmail", () => { + it("does not throw for valid email addresses", () => { + expect(() => validateEmail("user@example.com")).not.toThrow(); + expect(() => validateEmail("a@b.co")).not.toThrow(); + expect(() => validateEmail("user+tag@domain.org")).not.toThrow(); + }); + + it("throws ValidationError for invalid email format", () => { + expect(() => validateEmail("invalid")).toThrow(ValidationError); + expect(() => validateEmail("missing@")).toThrow(ValidationError); + expect(() => validateEmail("@domain.com")).toThrow(ValidationError); + expect(() => validateEmail("no-at-sign.com")).toThrow(ValidationError); + expect(() => validateEmail("")).toThrow(ValidationError); + }); + + it("throws with message 'Invalid email format'", () => { + try { + validateEmail("bad"); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + expect((e as ValidationError).message).toBe("Invalid email format"); + } + }); + }); + + describe("sanitizeString", () => { + it("trims leading and trailing whitespace", () => { + expect(sanitizeString(" hello ")).toBe("hello"); + }); + + it("collapses multiple spaces to single space", () => { + expect(sanitizeString("a b c")).toBe("a b c"); + }); + + it("returns empty string for whitespace-only input", () => { + expect(sanitizeString(" ")).toBe(""); + }); + }); + + describe("slugify", () => { + it("converts to lowercase", () => { + expect(slugify("Hello World")).toBe("hello-world"); + }); + + it("replaces spaces with hyphens", () => { + expect(slugify("foo bar baz")).toBe("foo-bar-baz"); + }); + + it("removes non-word characters except hyphens", () => { + expect(slugify("Hello! World?")).toBe("hello-world"); + }); + + it("replaces multiple hyphens with single hyphen", () => { + expect(slugify("a---b")).toBe("a-b"); + }); + + it("trims hyphens from start and end", () => { + expect(slugify("-hello-")).toBe("hello"); + }); + + it("handles string conversion for non-string input", () => { + expect(slugify(123 as unknown as string)).toBe("123"); + }); + }); +}); diff --git a/server/utils/__tests__/helper-function.test.ts b/server/utils/__tests__/helper-function.test.ts new file mode 100644 index 0000000..e319988 --- /dev/null +++ b/server/utils/__tests__/helper-function.test.ts @@ -0,0 +1,77 @@ +/** + * Unit tests for server/utils/helper-function.ts. + * Covers getServerConfig() for development and production environments, + * including URL parsing and required env vars. Uses mocked env-validation. + */ +jest.mock("../../../config/env/env-validation", () => ({ + env: { + NODE_ENV: "development", + PORT: 5001, + API_PROD_URL: undefined as string | undefined, + }, +})); + +import { getServerConfig } from "../helper-function"; + +describe("helper-function", () => { + let env: { NODE_ENV: string; PORT: number; API_PROD_URL?: string }; + beforeEach(() => { + env = require("../../../config/env/env-validation").env; + env.NODE_ENV = "development"; + env.PORT = 5001; + env.API_PROD_URL = undefined; + }); + + describe("getServerConfig", () => { + it("returns development config when NODE_ENV is not production", () => { + env.NODE_ENV = "development"; + env.PORT = 4000; + env.NODE_ENV = "development"; + env.PORT = 4000; + const config = getServerConfig(); + expect(config).toEqual({ + host: "localhost", + port: 4000, + url: "http://localhost:4000", + }); + }); + + it("returns development config when NODE_ENV is test", () => { + env.NODE_ENV = "test"; + env.PORT = 5000; + const config = getServerConfig(); + expect(config.host).toBe("localhost"); + expect(config.port).toBe(5000); + expect(config.url).toContain("5000"); + }); + + it("throws when NODE_ENV is production and API_PROD_URL is missing", () => { + env.NODE_ENV = "production"; + env.API_PROD_URL = undefined; + expect(() => getServerConfig()).toThrow("API_PROD_URL environment variable is required"); + }); + + it("returns production config with parsed host and port from API_PROD_URL", () => { + env.NODE_ENV = "production"; + env.API_PROD_URL = "https://api.example.com:443"; + const config = getServerConfig(); + expect(config.host).toBe("api.example.com"); + expect(config.port).toBe(443); + expect(config.url).toBe("https://api.example.com:443"); + }); + + it("uses port 80 for http when no port in URL", () => { + env.NODE_ENV = "production"; + env.API_PROD_URL = "http://api.example.com"; + const config = getServerConfig(); + expect(config.port).toBe(80); + }); + + it("uses port 443 for https when no port in URL", () => { + env.NODE_ENV = "production"; + env.API_PROD_URL = "https://api.example.com"; + const config = getServerConfig(); + expect(config.port).toBe(443); + }); + }); +}); diff --git a/server/utils/__tests__/uuid-validator.test.ts b/server/utils/__tests__/uuid-validator.test.ts new file mode 100644 index 0000000..df6598d --- /dev/null +++ b/server/utils/__tests__/uuid-validator.test.ts @@ -0,0 +1,151 @@ +/** + * Unit tests for server/utils/validations/uuid-validator.ts. + * Covers UUIDValidator static methods and exported helpers: validateId, validateIds, + * isValidUUID, isValidUUIDv4, normalizeUUID, isUUID, validateUUIDBatch. + */ +import { + UUIDValidator, + validateId, + validateIds, + isValidUUID, + isValidUUIDv4, + normalizeUUID, + isUUID, + validateUUIDBatch, +} from "../validations/uuid-validator"; +import { ValidationError } from "../../../middlewares/errors/error-handler"; + +// UUID v4: version digit at index 14 must be "4" +const validV4 = "123e4567-e89b-42d3-a456-426614174000"; +// UUID v1: version digit at index 14 must be "1" (e.g. 1xxx in third group) +const validV1 = "550e8400-e29b-11d4-a716-446655440000"; +const nilUuid = "00000000-0000-0000-0000-000000000000"; + +describe("uuid-validator", () => { + describe("UUIDValidator.isValidUUID", () => { + it("returns true for valid UUID v4", () => { + expect(UUIDValidator.isValidUUID(validV4)).toBe(true); + }); + it("returns true for valid UUID v1", () => { + expect(UUIDValidator.isValidUUID(validV1)).toBe(true); + }); + it("returns false for empty or non-string", () => { + expect(UUIDValidator.isValidUUID("")).toBe(false); + expect(UUIDValidator.isValidUUID(null as unknown as string)).toBe(false); + expect(UUIDValidator.isValidUUID(undefined as unknown as string)).toBe(false); + }); + it("returns false for invalid format", () => { + expect(UUIDValidator.isValidUUID("not-a-uuid")).toBe(false); + expect(UUIDValidator.isValidUUID("123e4567-e89b-12d3-a456")).toBe(false); + }); + }); + + describe("UUIDValidator.isValidUUIDv4", () => { + it("returns true for valid UUID v4", () => { + expect(UUIDValidator.isValidUUIDv4(validV4)).toBe(true); + }); + it("returns false for non-v4 UUID", () => { + expect(UUIDValidator.isValidUUIDv4(validV1)).toBe(false); + }); + }); + + describe("UUIDValidator.validateAndNormalize / validateId", () => { + it("returns normalized lowercase UUID for valid input", () => { + expect(validateId(validV4, "id")).toBe(validV4.toLowerCase()); + }); + it("throws ValidationError for empty string", () => { + expect(() => validateId("", "id")).toThrow(ValidationError); + }); + it("throws ValidationError for invalid UUID", () => { + expect(() => validateId("invalid", "id")).toThrow(ValidationError); + }); + it("throws ValidationError for nil UUID", () => { + expect(() => validateId(nilUuid, "id")).toThrow(ValidationError); + }); + }); + + describe("validateIds", () => { + it("returns array of normalized UUIDs", () => { + const ids = [validV4, validV1]; + expect(validateIds(ids, "ids")).toEqual(ids.map((id) => id.toLowerCase())); + }); + it("throws for non-array", () => { + expect(() => validateIds(null as unknown as string[], "ids")).toThrow(ValidationError); + }); + it("throws for empty array", () => { + expect(() => validateIds([], "ids")).toThrow(ValidationError); + }); + it("throws for duplicate UUIDs", () => { + expect(() => validateIds([validV4, validV4], "ids")).toThrow(ValidationError); + }); + }); + + describe("isValidUUID / isValidUUIDv4 (exported)", () => { + it("isValidUUID matches UUIDValidator.isValidUUID", () => { + expect(isValidUUID(validV4)).toBe(true); + expect(isValidUUID("x")).toBe(false); + }); + it("isValidUUIDv4 matches UUIDValidator.isValidUUIDv4", () => { + expect(isValidUUIDv4(validV4)).toBe(true); + expect(isValidUUIDv4(validV1)).toBe(false); + }); + }); + + describe("normalizeUUID", () => { + it("returns normalized UUID or throws", () => { + expect(normalizeUUID(validV4)).toBe(validV4.toLowerCase()); + expect(() => normalizeUUID("bad")).toThrow(ValidationError); + }); + }); + + describe("isUUID type guard", () => { + it("returns true for valid UUID string", () => { + expect(isUUID(validV4)).toBe(true); + }); + it("returns false for non-string", () => { + expect(isUUID(123)).toBe(false); + expect(isUUID(null)).toBe(false); + }); + it("returns false for invalid string", () => { + expect(isUUID("not-uuid")).toBe(false); + }); + }); + + describe("validateUUIDBatch", () => { + it("returns validated array of UUID strings", () => { + const arr = [validV4, validV1]; + expect(validateUUIDBatch(arr)).toEqual(arr.map((id) => id.toLowerCase())); + }); + it("throws for non-array", () => { + expect(() => validateUUIDBatch(null as unknown as unknown[])).toThrow(ValidationError); + }); + it("throws when an element is not a string", () => { + expect(() => validateUUIDBatch([validV4, 123])).toThrow(ValidationError); + }); + }); + + describe("UUIDValidator.getVersion", () => { + it("returns version number for valid UUID", () => { + expect(UUIDValidator.getVersion(validV4)).toBe(4); + }); + it("returns null for invalid UUID", () => { + expect(UUIDValidator.getVersion("x")).toBeNull(); + }); + }); + + describe("UUIDValidator.isNilUUID / isMaxUUID", () => { + it("isNilUUID returns true only for nil UUID", () => { + expect(UUIDValidator.isNilUUID(nilUuid)).toBe(true); + expect(UUIDValidator.isNilUUID(validV4)).toBe(false); + }); + }); + + describe("UUIDValidator.equals", () => { + it("returns true for same UUID in different case", () => { + expect(UUIDValidator.equals(validV4, validV4.toUpperCase())).toBe(true); + }); + it("returns false for different UUIDs", () => { + expect(UUIDValidator.equals(validV4, validV1)).toBe(false); + }); + }); +}); diff --git a/server/utils/helper-function.ts b/server/utils/helper-function.ts index d3b3bbd..31bc2ab 100644 --- a/server/utils/helper-function.ts +++ b/server/utils/helper-function.ts @@ -1,30 +1,25 @@ -import dotenv from "dotenv"; -dotenv.config(); -const NODE_ENV = process.env.NODE_ENV || 'development'; -const PORT = process.env.PORT || 5001; - +import { env } from "../../config/env/env-validation"; // Environment-based configuration export const getServerConfig = () => { - if (NODE_ENV === 'production') { - const API_PROD_URL = process.env.API_PROD_URL; - if (!API_PROD_URL) { - throw new Error('API_PROD_URL environment variable is required in production'); - } - - // Parse the production URL to extract host and port - const url = new URL(API_PROD_URL); - return { - host: url.hostname, - port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80), - url: API_PROD_URL - }; - } else { - // Development configuration - return { - host: 'localhost', - port: PORT, - url: `http://localhost:${PORT}` - }; + if (env.NODE_ENV === "production") { + if (!env.API_PROD_URL) { + throw new Error("API_PROD_URL environment variable is required in production"); } + + // Parse the production URL to extract host and port + const url = new URL(env.API_PROD_URL); + return { + host: url.hostname, + port: url.port ? parseInt(url.port) : url.protocol === "https:" ? 443 : 80, + url: env.API_PROD_URL, + }; + } else { + // Development configuration + return { + host: "localhost", + port: env.PORT, + url: `http://localhost:${env.PORT}`, + }; + } }; diff --git a/server/utils/validations/email-validation.ts b/server/utils/validations/email-validation.ts index 481b9d7..5c72f49 100644 --- a/server/utils/validations/email-validation.ts +++ b/server/utils/validations/email-validation.ts @@ -1,24 +1,24 @@ import { ValidationError } from "../../../middlewares/errors/error-handler"; export const validateEmail = (email: string): void => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - throw new ValidationError('Invalid email format'); - } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + throw new ValidationError("Invalid email format"); + } }; export const sanitizeString = (str: string): string => { - return str.trim().replace(/\s+/g, ' '); + return str.trim().replace(/\s+/g, " "); }; export function slugify(text: string): string { - return text - .toString() // Convert to string - .toLowerCase() // Convert to lowercase - .trim() // Trim whitespace - .replace(/\s+/g, '-') // Replace spaces with - - .replace(/[^\w\-]+/g, '') // Remove all non-word chars - .replace(/\-\-+/g, '-') // Replace multiple - with single - - .replace(/^-+/, '') // Trim - from start - .replace(/-+$/, ''); // Trim - from end + return text + .toString() // Convert to string + .toLowerCase() // Convert to lowercase + .trim() // Trim whitespace + .replace(/\s+/g, "-") // Replace spaces with - + .replace(/[^\w\-]+/g, "") // Remove all non-word chars + .replace(/\-\-+/g, "-") // Replace multiple - with single - + .replace(/^-+/, "") // Trim - from start + .replace(/-+$/, ""); // Trim - from end } diff --git a/server/utils/validations/uuid-validator.ts b/server/utils/validations/uuid-validator.ts index ff6071a..b4fa3ef 100644 --- a/server/utils/validations/uuid-validator.ts +++ b/server/utils/validations/uuid-validator.ts @@ -1,188 +1,189 @@ -import { ValidationError } from "../../../middlewares/errors/error-handler" +import { ValidationError } from "../../../middlewares/errors/error-handler"; // UUID validation patterns const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const UUID_GENERAL_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const UUID_NIL = '00000000-0000-0000-0000-000000000000'; -const UUID_MAX = 'ffffffff-ffff-ffff-ffff-ffffffffffff'; +const UUID_GENERAL_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const UUID_NIL = "00000000-0000-0000-0000-000000000000"; +const UUID_MAX = "ffffffff-ffff-ffff-ffff-ffffffffffff"; /** * UUID validation utilities */ export class UUIDValidator { - /** - * Validates if a string is a valid UUID (any version) - */ - static isValidUUID(uuid: string): boolean { - if (!uuid || typeof uuid !== 'string') { - return false; - } - - // Remove any whitespace - const cleanUuid = uuid.trim(); - - // Check format - return UUID_GENERAL_REGEX.test(cleanUuid); + /** + * Validates if a string is a valid UUID (any version) + */ + static isValidUUID(uuid: string): boolean { + if (!uuid || typeof uuid !== "string") { + return false; } - /** - * Validates if a string is a valid UUID v4 - */ - static isValidUUIDv4(uuid: string): boolean { - if (!uuid || typeof uuid !== 'string') { - return false; - } + // Remove any whitespace + const cleanUuid = uuid.trim(); - const cleanUuid = uuid.trim(); - return UUID_V4_REGEX.test(cleanUuid); - } - - /** - * Validates and normalizes a UUID string - */ - static validateAndNormalize(uuid: string, fieldName: string = 'id'): string { - if (!uuid || typeof uuid !== 'string') { - throw new ValidationError(`${fieldName} must be a non-empty string`); - } - - const cleanUuid = uuid.trim().toLowerCase(); + // Check format + return UUID_GENERAL_REGEX.test(cleanUuid); + } - if (!this.isValidUUID(cleanUuid)) { - throw new ValidationError(`Invalid ${fieldName} format. Must be a valid UUID`); - } - - // Check for nil UUID (all zeros) - if (cleanUuid === UUID_NIL) { - throw new ValidationError(`${fieldName} cannot be a nil UUID`); - } - - return cleanUuid; + /** + * Validates if a string is a valid UUID v4 + */ + static isValidUUIDv4(uuid: string): boolean { + if (!uuid || typeof uuid !== "string") { + return false; } - /** - * Validates multiple UUIDs at once - */ - static validateMultiple(uuids: string[], fieldName: string = 'ids'): string[] { - if (!Array.isArray(uuids)) { - throw new ValidationError(`${fieldName} must be an array`); - } - - if (uuids.length === 0) { - throw new ValidationError(`${fieldName} array cannot be empty`); - } + const cleanUuid = uuid.trim(); + return UUID_V4_REGEX.test(cleanUuid); + } - if (uuids.length > 100) { - throw new ValidationError(`${fieldName} array cannot contain more than 100 items`); - } - - const validatedUuids: string[] = []; - const seenUuids = new Set(); - - for (let i = 0; i < uuids.length; i++) { - const uuid = this.validateAndNormalize(uuids[i], `${fieldName}[${i}]`); + /** + * Validates and normalizes a UUID string + */ + static validateAndNormalize(uuid: string, fieldName: string = "id"): string { + if (!uuid || typeof uuid !== "string") { + throw new ValidationError(`${fieldName} must be a non-empty string`); + } - // Check for duplicates - if (seenUuids.has(uuid)) { - throw new ValidationError(`Duplicate UUID found in ${fieldName}: ${uuid}`); - } + const cleanUuid = uuid.trim().toLowerCase(); - seenUuids.add(uuid); - validatedUuids.push(uuid); - } + if (!this.isValidUUID(cleanUuid)) { + throw new ValidationError(`Invalid ${fieldName} format. Must be a valid UUID`); + } - return validatedUuids; + // Check for nil UUID (all zeros) + if (cleanUuid === UUID_NIL) { + throw new ValidationError(`${fieldName} cannot be a nil UUID`); } - /** - * Extracts UUID version from a valid UUID - */ - static getVersion(uuid: string): number | null { - if (!this.isValidUUID(uuid)) { - return null; - } + return cleanUuid; + } - const versionChar = uuid.charAt(14); - return parseInt(versionChar, 10); + /** + * Validates multiple UUIDs at once + */ + static validateMultiple(uuids: string[], fieldName: string = "ids"): string[] { + if (!Array.isArray(uuids)) { + throw new ValidationError(`${fieldName} must be an array`); } - /** - * Checks if UUID is nil (all zeros) - */ - static isNilUUID(uuid: string): boolean { - return uuid.trim().toLowerCase() === UUID_NIL; + if (uuids.length === 0) { + throw new ValidationError(`${fieldName} array cannot be empty`); } - /** - * Checks if UUID is max (all f's) - */ - static isMaxUUID(uuid: string): boolean { - return uuid.trim().toLowerCase() === UUID_MAX; + if (uuids.length > 100) { + throw new ValidationError(`${fieldName} array cannot contain more than 100 items`); } - /** - * Generates a random UUID v4 (requires crypto module) - */ - static generateV4(): string { + const validatedUuids: string[] = []; + const seenUuids = new Set(); - try { + for (let i = 0; i < uuids.length; i++) { + const uuid = this.validateAndNormalize(uuids[i], `${fieldName}[${i}]`); - return crypto.randomUUID(); - } catch (error) { - // Fallback implementation - return this.generateV4Fallback(); - } + // Check for duplicates + if (seenUuids.has(uuid)) { + throw new ValidationError(`Duplicate UUID found in ${fieldName}: ${uuid}`); + } + seenUuids.add(uuid); + validatedUuids.push(uuid); + } - // For browser environment - if (typeof crypto !== 'undefined' && crypto.randomUUID) { - return crypto.randomUUID(); - } + return validatedUuids; + } - // Fallback implementation - return this.generateV4Fallback(); + /** + * Extracts UUID version from a valid UUID + */ + static getVersion(uuid: string): number | null { + if (!this.isValidUUID(uuid)) { + return null; } - /** - * Fallback UUID v4 generator - */ - private static generateV4Fallback(): string { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + const versionChar = uuid.charAt(14); + return parseInt(versionChar, 10); + } + + /** + * Checks if UUID is nil (all zeros) + */ + static isNilUUID(uuid: string): boolean { + return uuid.trim().toLowerCase() === UUID_NIL; + } + + /** + * Checks if UUID is max (all f's) + */ + static isMaxUUID(uuid: string): boolean { + return uuid.trim().toLowerCase() === UUID_MAX; + } + + /** + * Generates a random UUID v4 (requires crypto module) + */ + static generateV4(): string { + try { + return crypto.randomUUID(); + } catch (error) { + // Fallback implementation + return this.generateV4Fallback(); + } + + // For browser environment + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); } - /** - * Converts UUID to different formats - */ - static format(uuid: string, format: 'uppercase' | 'lowercase' | 'canonical' = 'lowercase'): string { - const validUuid = this.validateAndNormalize(uuid); - - switch (format) { - case 'uppercase': - return validUuid.toUpperCase(); - case 'lowercase': - return validUuid.toLowerCase(); - case 'canonical': - return validUuid.toLowerCase(); - default: - return validUuid; - } + // Fallback implementation + return this.generateV4Fallback(); + } + + /** + * Fallback UUID v4 generator + */ + private static generateV4Fallback(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + + /** + * Converts UUID to different formats + */ + static format( + uuid: string, + format: "uppercase" | "lowercase" | "canonical" = "lowercase" + ): string { + const validUuid = this.validateAndNormalize(uuid); + + switch (format) { + case "uppercase": + return validUuid.toUpperCase(); + case "lowercase": + return validUuid.toLowerCase(); + case "canonical": + return validUuid.toLowerCase(); + default: + return validUuid; } + } - /** - * Compares two UUIDs for equality (case-insensitive) - */ - static equals(uuid1: string, uuid2: string): boolean { - try { - const normalized1 = this.validateAndNormalize(uuid1); - const normalized2 = this.validateAndNormalize(uuid2); - return normalized1 === normalized2; - } catch { - return false; - } + /** + * Compares two UUIDs for equality (case-insensitive) + */ + static equals(uuid1: string, uuid2: string): boolean { + try { + const normalized1 = this.validateAndNormalize(uuid1); + const normalized2 = this.validateAndNormalize(uuid2); + return normalized1 === normalized2; + } catch { + return false; } + } } /** @@ -192,174 +193,173 @@ export class UUIDValidator { /** * Validates a single UUID ID */ -export const validateId = (id: string, fieldName: string = 'id'): string => { - return UUIDValidator.validateAndNormalize(id, fieldName); +export const validateId = (id: string, fieldName: string = "id"): string => { + return UUIDValidator.validateAndNormalize(id, fieldName); }; /** * Validates multiple UUID IDs */ -export const validateIds = (ids: string[], fieldName: string = 'ids'): string[] => { - return UUIDValidator.validateMultiple(ids, fieldName); +export const validateIds = (ids: string[], fieldName: string = "ids"): string[] => { + return UUIDValidator.validateMultiple(ids, fieldName); }; /** * Quick UUID validation check */ export const isValidUUID = (uuid: string): boolean => { - return UUIDValidator.isValidUUID(uuid); + return UUIDValidator.isValidUUID(uuid); }; /** * Quick UUID v4 validation check */ export const isValidUUIDv4 = (uuid: string): boolean => { - return UUIDValidator.isValidUUIDv4(uuid); + return UUIDValidator.isValidUUIDv4(uuid); }; /** * Generate a new UUID v4 */ export const generateUUID = (): string => { - return UUIDValidator.generateV4(); + return UUIDValidator.generateV4(); }; /** * Normalize UUID to lowercase */ export const normalizeUUID = (uuid: string): string => { - return UUIDValidator.validateAndNormalize(uuid); + return UUIDValidator.validateAndNormalize(uuid); }; /** * Type guard for UUID validation */ export const isUUID = (value: any): value is string => { - return typeof value === 'string' && UUIDValidator.isValidUUID(value); + return typeof value === "string" && UUIDValidator.isValidUUID(value); }; /** * Custom validation decorator for class-validator (if using) */ -export const IsUUID = (version?: 'all' | '4', options?: { message?: string }) => { - return (target: any, propertyName: string) => { - // This would integrate with class-validator if you're using it - // Implementation depends on your validation library - }; +export const IsUUID = (version?: "all" | "4", options?: { message?: string }) => { + return (target: any, propertyName: string) => { + // This would integrate with class-validator if you're using it + // Implementation depends on your validation library + }; }; /** * UUID validation middleware for Express routes */ export const validateUUIDParam = (paramName: string) => { - return (req: any, res: any, next: any) => { - try { - const uuid = req.params[paramName]; - req.params[paramName] = validateId(uuid, paramName); - next(); - } catch (error) { - res.status(400).json({ - error: 'Invalid parameter', - message: error instanceof Error ? error.message : 'Invalid UUID format' - }); - } - }; + return (req: any, res: any, next: any) => { + try { + const uuid = req.params[paramName]; + req.params[paramName] = validateId(uuid, paramName); + next(); + } catch (error) { + res.status(400).json({ + error: "Invalid parameter", + message: error instanceof Error ? error.message : "Invalid UUID format", + }); + } + }; }; /** * UUID validation for query parameters */ export const validateUUIDQuery = (queryName: string, required: boolean = false) => { - return (req: any, res: any, next: any) => { - try { - const uuid = req.query[queryName]; - - if (!uuid && required) { - return res.status(400).json({ - error: 'Missing required parameter', - message: `${queryName} is required` - }); - } - - if (uuid) { - req.query[queryName] = validateId(uuid as string, queryName); - } - - next(); - } catch (error) { - res.status(400).json({ - error: 'Invalid query parameter', - message: error instanceof Error ? error.message : 'Invalid UUID format' - }); - } - }; + return (req: any, res: any, next: any) => { + try { + const uuid = req.query[queryName]; + + if (!uuid && required) { + return res.status(400).json({ + error: "Missing required parameter", + message: `${queryName} is required`, + }); + } + + if (uuid) { + req.query[queryName] = validateId(uuid as string, queryName); + } + + next(); + } catch (error) { + res.status(400).json({ + error: "Invalid query parameter", + message: error instanceof Error ? error.message : "Invalid UUID format", + }); + } + }; }; /** * Batch UUID validation for arrays */ export const validateUUIDBatch = (uuids: unknown[]): string[] => { - if (!Array.isArray(uuids)) { - throw new ValidationError('Expected an array of UUIDs'); - } + if (!Array.isArray(uuids)) { + throw new ValidationError("Expected an array of UUIDs"); + } - return uuids.map((uuid, index) => { - if (typeof uuid !== 'string') { - throw new ValidationError(`Item at index ${index} is not a string`); - } - return validateId(uuid, `item[${index}]`); - }); + return uuids.map((uuid, index) => { + if (typeof uuid !== "string") { + throw new ValidationError(`Item at index ${index} is not a string`); + } + return validateId(uuid, `item[${index}]`); + }); }; /** * UUID validation with custom error messages */ export const validateIdWithMessage = ( - id: string, - fieldName: string = 'id', - customMessage?: string + id: string, + fieldName: string = "id", + customMessage?: string ): string => { - try { - return validateId(id, fieldName); - } catch (error) { - if (customMessage && error instanceof ValidationError) { - throw new ValidationError(customMessage); - } - throw error; + try { + return validateId(id, fieldName); + } catch (error) { + if (customMessage && error instanceof ValidationError) { + throw new ValidationError(customMessage); } + throw error; + } }; - // Example usage and tests export const UUIDValidatorTests = { - runTests: () => { - console.log('Running UUID validation tests...'); - - // Test valid UUIDs - const validUUIDs = [ - '123e4567-e89b-12d3-a456-426614174000', - '550e8400-e29b-41d4-a716-446655440000', - 'f47ac10b-58cc-4372-a567-0e02b2c3d479' - ]; - - validUUIDs.forEach(uuid => { - console.assert(isValidUUID(uuid), `Should be valid: ${uuid}`); - }); + runTests: () => { + console.log("Running UUID validation tests..."); + + // Test valid UUIDs + const validUUIDs = [ + "123e4567-e89b-12d3-a456-426614174000", + "550e8400-e29b-41d4-a716-446655440000", + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ]; + + validUUIDs.forEach((uuid) => { + console.assert(isValidUUID(uuid), `Should be valid: ${uuid}`); + }); - // Test invalid UUIDs - const invalidUUIDs = [ - 'invalid-uuid', - '123e4567-e89b-12d3-a456', - '123e4567-e89b-12d3-a456-426614174000-extra', - '', - null, - undefined - ]; - - invalidUUIDs.forEach(uuid => { - console.assert(!isValidUUID(uuid as string), `Should be invalid: ${uuid}`); - }); + // Test invalid UUIDs + const invalidUUIDs = [ + "invalid-uuid", + "123e4567-e89b-12d3-a456", + "123e4567-e89b-12d3-a456-426614174000-extra", + "", + null, + undefined, + ]; + + invalidUUIDs.forEach((uuid) => { + console.assert(!isValidUUID(uuid as string), `Should be invalid: ${uuid}`); + }); - console.log('UUID validation tests completed!'); - } + console.log("UUID validation tests completed!"); + }, }; diff --git a/tsconfig.json b/tsconfig.json index 7519052..0f4ae45 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,21 +4,20 @@ "module": "commonjs", "outDir": "./dist", "rootDir": ".", - "strict": true, // Must be false to fully suppress type errors - "moduleResolution": "node", + "strict": true, // Must be false to fully suppress type errors + "moduleResolution": "node", "esModuleInterop": true, "skipLibCheck": true, // Moved inside compilerOptions - "types": [ - "node", - "jest" - ], "resolveJsonModule": true }, "include": [ "index.ts", + "app.ts", "server/**/*.ts", "config/**/*.ts", "middlewares/**/*.ts", + "types/**/*.d.ts", + "types/**/*.ts", "drizzle.config.ts" ], "exclude": [ @@ -28,4 +27,4 @@ "**/node_modules/drizzle-orm/sqlite-core*", "**/node_modules/.pnpm/**/sqlite-core*" ] -} +} \ No newline at end of file diff --git a/types/express-app.ts b/types/express-app.ts new file mode 100644 index 0000000..6e86813 --- /dev/null +++ b/types/express-app.ts @@ -0,0 +1,28 @@ +/** + * Project-wide Express application type. + * Use this instead of Express/Application so all files see the same type; + * in this project the types from "express" can resolve without .use/.set/.get in some modules. + */ +export interface ExpressApp { + use(...args: unknown[]): ExpressApp; + set(setting: string, val: unknown): ExpressApp; + get(...args: unknown[]): unknown; + [key: string]: unknown; +} + +/** Next callback for middleware (use when Express NextFunction has no call signatures). */ +export type ExpressNext = (err?: unknown) => void; + +/** Minimal request shape for logging (use when Express Request does not expose path/method/requestId). */ +export interface ExpressRequestLike { + path: string; + method: string; + requestId?: string; +} + +/** Minimal response shape (use when Express Response does not expose .json/.on/statusCode). */ +export interface ExpressResponse { + statusCode: number; + json(body?: unknown): ExpressResponse | void; + on(event: string, fn: () => void): void; +} diff --git a/types/supertest.d.ts b/types/supertest.d.ts new file mode 100644 index 0000000..cfd14e2 --- /dev/null +++ b/types/supertest.d.ts @@ -0,0 +1,15 @@ +declare module "supertest" { + import type { ExpressApp } from "./express-app"; + + interface SuperTest { + get(path: string): SuperTest; + post(path: string): SuperTest; + options(path: string): SuperTest; + set(field: string, val: string): SuperTest; + expect(status: number): SuperTest; + then(fn: (res: { status: number; body: unknown; headers: Record }) => T): Promise; + } + + function request(app: ExpressApp): SuperTest; + export = request; +} diff --git a/types/swagger-ui-express.d.ts b/types/swagger-ui-express.d.ts new file mode 100644 index 0000000..7cb31b4 --- /dev/null +++ b/types/swagger-ui-express.d.ts @@ -0,0 +1,13 @@ +declare module "swagger-ui-express" { + import { RequestHandler } from "express"; + interface SwaggerUiOptions { + [key: string]: unknown; + } + function serve(): RequestHandler[]; + function setup( + spec: object, + options?: SwaggerUiOptions + ): RequestHandler; + const _default: { serve: typeof serve; setup: typeof setup }; + export = _default; +}