diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000000..f94936bde6 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,37 @@ +name: Backend CI + +on: + push: + branches: [dev, qa, main] + paths: + - 'server/**' + pull_request: + branches: [dev, qa, main] + paths: + - 'server/**' + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Type check + run: pnpm --filter server run typecheck + + - name: Lint + run: pnpm --filter server run lint + + - name: Test + run: pnpm --filter server run test + + - name: Build + run: pnpm --filter server run build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..9c66902ec6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: Monorepo CI + +on: + push: + branches: [dev, qa, main] + pull_request: + branches: [dev, qa, main] + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + admin: ${{ steps.filter.outputs.admin }} + client: ${{ steps.filter.outputs.client }} + server: ${{ steps.filter.outputs.server }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + admin: 'admin/**' + client: 'client/**' + server: 'server/**' + + build: + needs: changes + strategy: + matrix: + component: [admin, client, server] + runs-on: ubuntu-latest + if: | + (matrix.component == 'admin' && needs.changes.outputs.admin == 'true') || + (matrix.component == 'client' && needs.changes.outputs.client == 'true') || + (matrix.component == 'server' && needs.changes.outputs.server == 'true') + defaults: + run: + working-directory: ${{ matrix.component }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + cache: "pnpm" + cache-dependency-path: ${{ matrix.component }}/pnpm-lock.yaml + + - run: pnpm install --no-frozen-lockfile --recursive + + - name: Type check (server only) + if: matrix.component == 'server' + run: pnpm exec tsc --noEmit + + - run: pnpm run lint + - run: pnpm run test + - run: pnpm run build \ No newline at end of file diff --git a/.github/workflows/enforce-pr-source.yml b/.github/workflows/enforce-pr-source.yml new file mode 100644 index 0000000000..85048d3557 --- /dev/null +++ b/.github/workflows/enforce-pr-source.yml @@ -0,0 +1,67 @@ +name: Enforce PR Source Branches + +on: + pull_request: + branches: [qa, main] + types: [opened, synchronize, reopened, edited] + +jobs: + check-qa-source: + name: Check PRs targeting `qa` + if: github.base_ref == 'qa' + runs-on: ubuntu-latest + steps: + - name: Verify source branch is `dev` + run: | + if [[ "${{ github.head_ref }}" != "dev" ]]; then + echo "❌ ERROR: PRs to 'qa' must come from 'dev'!" + echo "" + echo "Valid path to qa:" + echo " dev → qa" + echo "" + echo "Your PR is from: ${{ github.head_ref }}" + echo "Expected source: dev" + echo "" + echo "Please:" + echo "1. Close this PR" + echo "2. Merge your changes into 'dev' branch first" + echo "3. Create a new PR from 'dev' to 'qa'" + exit 1 + fi + + - name: Optional – Suggest release preparation + run: | + echo "✅ PR from dev to qa is valid. Continue with QA testing." + + check-main-source: + name: Check PRs targeting `main` + if: github.base_ref == 'main' + runs-on: ubuntu-latest + steps: + - name: Verify source branch is `qa` + run: | + if [[ "${{ github.head_ref }}" != "qa" ]]; then + echo "❌ ERROR: Direct PR to 'main' is not allowed!" + echo "" + echo "Valid path to main:" + echo " dev → qa → main" + echo "" + echo "Your PR is from: ${{ github.head_ref }}" + echo "Expected source: qa" + echo "" + echo "Please:" + echo "1. Close this PR" + echo "2. Merge your changes into 'qa' branch first" + echo "3. Create a new PR from 'qa' to 'main'" + exit 1 + fi + + - name: Enforce release version naming (optional) + run: | + TITLE="${{ github.event.pull_request.title }}" + if [[ ! "$TITLE" =~ ^Release\ version\ [0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "⚠️ Suggestion: PR title should follow 'Release version X.Y.Z'" + # Change to "exit 1" if you want to enforce the naming strictly + else + echo "✅ Valid release title format." + fi diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000000..70c6c19165 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,78 @@ +name: Frontend CI + +on: + push: + branches: [dev, qa, main] + paths: + - "admin/**" + - "client/**" + - ".github/workflows/frontend.yml" + pull_request: + branches: [dev, qa, main] + paths: + - "admin/**" + - "client/**" + - ".github/workflows/frontend.yml" + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + admin: ${{ steps.filter.outputs.admin }} + client: ${{ steps.filter.outputs.client }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + admin: + - 'admin/**' + client: + - 'client/**' + + build-admin: + needs: changes + if: needs.changes.outputs.admin == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: admin + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + cache: "pnpm" + cache-dependency-path: admin/pnpm-lock.yaml + + - run: pnpm install --no-frozen-lockfile + - run: pnpm exec tsc --noEmit + - run: pnpm run lint + - run: pnpm run test + - run: pnpm run build + + build-client: + needs: changes + if: needs.changes.outputs.client == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: client + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + cache: "pnpm" + cache-dependency-path: client/pnpm-lock.yaml + + - run: pnpm install --no-frozen-lockfile + - run: pnpm exec tsc --noEmit + - run: pnpm run lint + - run: pnpm run test + - run: pnpm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..a2501db7c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Logs +*.log +lerna-debug.log* +logs +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build / Distribution +dist +dist-ssr + +# Editor / IDE +.agents +.agent +.idea +.vscode +!.vscode/extensions.json +*.njsproj +*.ntvs* +*.sln +*.suo +*.sw? + +# Root +node_modules/ + +# System / OS +.DS_Store + +# ------------------------------------- + +# Admin +/admin/.env +/admin/.vite/ +/admin/dist/ +/admin/node_modules + +# Client +/client/.env +/client/.vite/ +/client/dist/ +/client/node_modules + +# Server +/server/.env +/server/dist/ +/server/node_modules + +# Test coverage +coverage/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000000..5ee7abd87c --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000000..b51904ccfb --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,96 @@ +#!/bin/sh + +# Pre-push hook — runs full typecheck + lint + build + test only on the sub-projects that changed. +# Reads from stdin in the format: + +changed_files="" +while read local_ref local_sha remote_ref remote_sha; do + # Skip branch deletion + if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then + continue + fi + + # Determine what to compare against + if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then + # New branch — compare against merge-base with main or dev + against=$(git merge-base HEAD main 2>/dev/null || git merge-base HEAD dev 2>/dev/null || echo "$local_sha~1") + else + against="$remote_sha" + fi + + new_changed=$(git diff --name-only "$against".."$local_sha" 2>/dev/null) + if [ -n "$new_changed" ]; then + changed_files="$changed_files +$new_changed" + fi +done + +if [ -z "$changed_files" ] || [ "$changed_files" = "" ]; then + echo "✅ No file changes detected, skipping pre-push checks." + exit 0 +fi + +# Categorize changed files by sub-project +CLIENT_FILES="" +ADMIN_FILES="" +SERVER_FILES="" + +while IFS= read -r file; do + [ -z "$file" ] && continue + + [ ! -f "$file" ] && continue + case "$file" in + client/*) CLIENT_FILES="$CLIENT_FILES $file" ;; + admin/*) ADMIN_FILES="$ADMIN_FILES $file" ;; + server/*) SERVER_FILES="$SERVER_FILES $file" ;; + esac +done <&1) || failed=1 + (cd "$ROOT_DIR/client" && pnpm test 2>&1) || failed=1 + (cd "$ROOT_DIR/client" && pnpm build 2>&1) || failed=1 + echo " Client checks complete" +fi + +if [ -n "$ADMIN_FILES" ]; then + echo "" + echo "📦 Checking admin …" + ADMIN_FILES_RELATIVE=$(printf "%s" "$ADMIN_FILES" | sed 's|^[[:space:]]*||; s| admin/| |g; s|^admin/||') + (cd "$ROOT_DIR/admin" && pnpm exec eslint --no-warn-ignored $ADMIN_FILES_RELATIVE 2>&1) || failed=1 + (cd "$ROOT_DIR/admin" && pnpm test 2>&1) || failed=1 + (cd "$ROOT_DIR/admin" && pnpm build 2>&1) || failed=1 + echo " Admin checks complete" +fi + +if [ -n "$SERVER_FILES" ]; then + echo "" + echo "📦 Checking server …" + SERVER_FILES_RELATIVE=$(printf "%s" "$SERVER_FILES" | sed 's|^[[:space:]]*||; s| server/| |g; s|^server/||') + (cd "$ROOT_DIR/server" && pnpm exec eslint --no-warn-ignored $SERVER_FILES_RELATIVE 2>&1) || failed=1 + (cd "$ROOT_DIR/server" && pnpm test 2>&1) || failed=1 + (cd "$ROOT_DIR/server" && pnpm build 2>&1) || failed=1 + echo " Server checks complete" +fi + +echo "" +echo "───────────────────────────────────────" +if [ "$failed" = 1 ]; then + echo "❌ Pre-push checks FAILED — push blocked." + exit 1 +fi +echo "✅ All checks passed — push allowed." +exit 0 diff --git a/BMV full logo nontransparent.png b/BMV full logo nontransparent.png new file mode 100644 index 0000000000..4e77f83291 Binary files /dev/null and b/BMV full logo nontransparent.png differ diff --git a/BMV full logo.png b/BMV full logo.png new file mode 100644 index 0000000000..e66c1f6c21 Binary files /dev/null and b/BMV full logo.png differ diff --git a/README.md b/README.md index 68d8b36b8e..ea8c844f07 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,16 @@ To maintain a high standard of code, all Pull Requests must use our [standard te 4. **Sign the AI Disclosure** (Confirming you have reviewed any AI-generated code). 5. **Attach Screenshots** (If your PR includes UI changes). +## 📚 Documentation + +| Document | Description | +|---|---| +| [Setup Guide](SETUP.md) | Development environment setup, environment variables, and running the project | +| [Architecture](docs/architecture.md) | System architecture, module patterns, middleware stack, and RBAC | +| [API Overview](docs/api-overview.md) | Authentication flow, response format, pagination, webhooks, and route map | +| [Server Modules](docs/modules.md) | Catalog of all 14 server feature modules | +| [Pagination](.agents/PAGINATION.md) | Detailed pagination middleware and utilities reference | +| [Security Policy](SECURITY.md) | How to report vulnerabilities and security measures | +| [Contribution Guidelines](CONTRIBUTING.md) | How to contribute, branch naming, and PR workflow | + **BookMyVenue belongs to all of us. Join WeCode today and let's build something amazing together!** diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..affe1230b5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,56 @@ +# Security Policy + +## Supported Versions + +Currently in Phase 1 MVP development — only the latest commit on `main`/`dev` is supported. + +## Reporting a Vulnerability + +To report a security vulnerability, please email **me@ashishshaiju.com** (do **NOT** open a public GitHub issue). + +You should receive an acknowledgment within 48 hours. If you don't, follow up to ensure we received your report. + +We ask that you: + +- Provide a detailed description of the vulnerability +- Include steps to reproduce if possible +- Allow us reasonable time to address the issue before any public disclosure + +### What We Do + +- Acknowledge receipt within 2 business days +- Investigate and provide a timeline for fix +- Release a patch and credit the reporter (if desired) +- Backport fixes to supported versions as needed + +## Scope + +- **Server** (`server/`): Express API, authentication (JWT), payment processing (Razorpay), data handling +- **Client** (`client/`): User-facing React app, API communication +- **Admin** (`admin/`): Admin dashboard, user/venue management + +## Security Measures + +- **Authentication**: JWT-based access + refresh token flow with HTTP-only cookies +- **Rate Limiting**: Global (100 req/15min), login (10 req/15min), sensitive endpoints (5 req/15min) +- **Headers**: Helmet.js for security headers +- **Payment Webhooks**: Razorpay HMAC-SHA256 signature verification on raw request body +- **Input Validation**: Zod schemas on all endpoints with centralized validation middleware +- **Dependencies**: Regular updates via pnpm, Dependabot configured +- **Secrets**: All secrets and API keys managed via environment variables, never hardcoded + +## Data Handling + +- Passwords hashed with bcrypt +- MongoDB connection uses SRV + TLS in production +- Email service via Resend (API key authenticated) +- File uploads processed through Cloudinary (not stored on application servers) + +## Disclosure Policy + +We follow coordinated disclosure: + +1. Reporter submits vulnerability privately +2. We investigate and fix +3. We release a security advisory with the fix +4. Reporter may disclose publicly after advisory release diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000000..1de97cf477 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,177 @@ +# BookMyVenue — Development Setup Guide + +## Prerequisites + +- Node.js 22+ +- pnpm 10.16.1 (`npm install -g pnpm@10.16.1`) +- MongoDB (local instance or MongoDB Atlas) +- Git + +## Getting Started + +```bash +git clone https://github.com/ashishshaiju/BookMyVenue.git +cd BookMyVenue +pnpm install +``` + +## Environment Variables + +### Server (`server/.env`) + +Copy `server/.env.example` to `server/.env`. Key variables: + +| Variable | Description | Default | +|---|---|---| +| `MONGODB_URI` | MongoDB connection string | `mongodb://localhost:27017/book-my-venue` | +| `PORT` | Server port | `3000` | +| `NODE_ENV` | Environment | `development` | +| `JWT_ACCESS_SECRET` | JWT access token secret | (generate random 64-char hex) | +| `JWT_REFRESH_SECRET` | JWT refresh token secret | (generate random 64-char hex) | +| `ACCESS_TOKEN_EXPIRY` | Access token lifetime | `15m` | +| `REFRESH_TOKEN_EXPIRY` | Refresh token lifetime | `7d` | +| `RESEND_API_KEY` | Resend email API key | — | +| `EMAIL_FROM_NAME` | Sender name | `BookMyVenue` | +| `EMAIL_FROM_EMAIL` | Sender email | `noreply@bookmyvenue.com` | +| `FRONTEND_URL` | Client app URL | `http://localhost:5173` | +| `CLOUDINARY_CLOUD_NAME` | Cloudinary cloud name | — | +| `CLOUDINARY_API_KEY` | Cloudinary API key | — | +| `CLOUDINARY_API_SECRET` | Cloudinary API secret | — | +| `RAZORPAY_KEY_ID` | Razorpay key ID | — | +| `RAZORPAY_KEY_SECRET` | Razorpay key secret | — | +| `RAZORPAY_WEBHOOK_SECRET` | Razorpay webhook secret | — | +| `SWAGGER_USER` | Swagger UI basic auth user | `admin` | +| `SWAGGER_PASS` | Swagger UI basic auth pass | `password` | + +### Client (`client/.env`) + +```env +VITE_API_BASE_URL=http://localhost:3000/api/v1 +``` + +### Admin (`admin/.env`) + +```env +VITE_API_BASE_URL=http://localhost:3003/api/v1 +VITE_CLIENT_URL=http://localhost:5173 +``` + +## Database Setup + +1. Start MongoDB locally or use MongoDB Atlas +2. The server connects automatically on startup using `MONGODB_URI` +3. Required after a fresh database: + +```bash +cd server +pnpm script seed:rbac +``` + +This seeds roles, permissions, and RBAC data. The server verifies this at startup via `verifyRbacSeed()` and exits if missing. + +## Running the Workspaces + +### Root (all workspaces simultaneously) + +```bash +pnpm dev +``` + +Starts server (port 3000/3003), client (port 5173), and admin (port 5174) in parallel. + +### Server (Backend) + +```bash +cd server +pnpm dev # Start with auto-reload (tsx watch) +pnpm build # Compile TypeScript to dist/ +pnpm start # Run built server (node dist/server.js) +pnpm lint # ESLint +pnpm typecheck # tsc --noEmit +``` + +Runs on `http://localhost:3000` (configurable via `PORT`). Uses Express 5 + Mongoose. Swagger API docs at `http://localhost:3000/api/v1/swagger` (basic auth: user/pass from env). + +### Client (User App) + +```bash +cd client +pnpm dev # Vite dev server on http://localhost:5173 (--host) +pnpm build # tsc -b && vite build +pnpm lint # ESLint +pnpm preview # Preview production build +``` + +User-facing React 19 app for browsing venues and booking. + +### Admin (Admin Dashboard) + +```bash +cd admin +pnpm dev # Vite dev server on http://localhost:5174 +pnpm build # tsc -b && vite build +pnpm lint # ESLint +pnpm preview # Preview production build +``` + +Owner/admin dashboard built with React 19 + Zustand + TanStack React Table. + +## Docker + +The project includes a server-only Docker Compose setup for local dev: + +```bash +docker compose up +``` + +This runs the server in a container, mapping host port 3000 to container port 3003. It reads `server/.env` for configuration and persists logs to `server/logs/`. + +Note: Docker Compose does NOT include MongoDB or the frontend apps — it's for smoke-testing the containerised server only. + +## Background Workers + +The server starts 3 background workers automatically: + +- **email.worker.ts** — Processes email tasks from the `email-task` MongoDB collection +- **banExpiry.worker.ts** — Handles automatic ban expiration +- **venueEditDeadline.worker.ts** — Manages venue edit deadlines + +## Testing + +```bash +# Run tests in current workspace +pnpm test + +# Run tests in all workspaces +pnpm -r test + +# Run a specific workspace command from root +pnpm --filter server exec +pnpm --filter client exec +pnpm --filter admin exec +``` + +- **Server**: vitest with `mongodb-memory-server` + `supertest` for integration tests +- **Client**: vitest with jsdom + @testing-library/react +- **Admin**: vitest with jsdom + @testing-library/react + +## Pre-commit / Pre-push + +- **pre-commit**: lint-staged (ESLint on staged files → `tsc --noEmit` → test per workspace) +- **pre-push**: ESLint + test + build on changed workspaces + +## Useful Commands + +```bash +pnpm install:all # Install all dependencies +pnpm build # Build all workspaces +pnpm lint # Lint all workspaces +pnpm format # Format code in all workspaces +``` + +## Troubleshooting + +- **"RBAC data missing" on server startup**: Run `pnpm script seed:rbac` in the server directory +- **Build failures**: Ensure you have the correct Node version (22+) and pnpm version (10.16.1) +- **MongoDB connection refused**: Verify MongoDB is running locally or check `MONGODB_URI` +- **Client can't reach API**: Check `VITE_API_BASE_URL` in `client/.env` diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000000..d7d77c98a0 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +*.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000000..64ac86db9e --- /dev/null +++ b/admin/README.md @@ -0,0 +1,81 @@ +# BookMyVenue — Admin Dashboard + +The owner and admin dashboard for managing venues, bookings, users, and moderation. Built with React 19 + Vite 8 + Zustand + TanStack React Table. + +## Tech Stack + +- **Framework**: React 19 with TypeScript ~6.0 +- **Build Tool**: Vite 8 with rolldown +- **Styling**: TailwindCSS 4 +- **Routing**: React Router 7 +- **Server State**: TanStack React Query 5 +- **Client State**: Zustand 5 +- **HTTP Client**: Axios +- **Tables**: TanStack React Table 8 +- **Charts**: Recharts +- **UI Components**: Radix UI (Dialog, Slot, Tooltip), class-variance-authority +- **Date Handling**: date-fns, react-day-picker +- **Notifications**: react-hot-toast + +## Available Scripts + +| Script | Description | +|---|---| +| `pnpm dev` | Start Vite dev server (local only) | +| `pnpm build` | Type-check (`tsc -b`) then build (`vite build`) | +| `pnpm test` | Run vitest tests | +| `pnpm test:watch` | Run tests in watch mode | +| `pnpm test:coverage` | Run tests with coverage report | +| `pnpm lint` | ESLint check | +| `pnpm lint:fix` | Auto-fix ESLint issues | +| `pnpm preview` | Preview production build locally | + +## Development + +```bash +# Install dependencies (from repo root) +pnpm install + +# Start dev server (default: http://localhost:5174) +pnpm dev + +# Run tests +pnpm test +``` + +## Environment Variables + +Create `admin/.env`: + +```env +VITE_API_BASE_URL=http://localhost:3003/api/v1 +VITE_CLIENT_URL=http://localhost:5173 +``` + +## Project Structure + +``` +src/ +├── pages/ # Page components (routed) +├── components/ # Reusable UI components +│ └── guards/ # Auth guards (AuthGuard, RoleGuard, OwnerGuard) +├── hooks/ # Custom React hooks (useApi, useModal, useToast) +├── services/ # API service layer (Axios) +├── store/ # Zustand stores (useAuthStore, useAppStore, useModalStore) +├── utils/ # Utility functions +├── constants/ # App constants +├── types/ # TypeScript type definitions +├── tests/ # Vitest test files +├── config/ # Axios instance and query client config +├── App.tsx # Root app component +└── main.tsx # Entry point +``` + +## Key Differences from Client + +- Owner/admin dashboard (authenticated-only routes) +- Zustand for client state management (no React Context) +- TanStack React Table for data tables +- Recharts for analytics charts +- Auth guards (AuthGuard, RoleGuard, OwnerGuard) for route protection +- No map or animation libraries diff --git a/admin/components.json b/admin/components.json new file mode 100644 index 0000000000..0a5b2f70b4 --- /dev/null +++ b/admin/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/admin/eslint.config.js b/admin/eslint.config.js new file mode 100644 index 0000000000..8f99594dcc --- /dev/null +++ b/admin/eslint.config.js @@ -0,0 +1,44 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; +import eslintComments from "@eslint-community/eslint-plugin-eslint-comments"; + +export default defineConfig([ + globalIgnores(["dist"]), + js.configs.recommended, + ...tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + { + files: ["**/*.{ts,tsx}"], + plugins: { + "eslint-comments": eslintComments, + }, + languageOptions: { + globals: globals.browser, + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "eslint-comments/no-use": ["error", { allow: [] }], + "eslint-comments/no-unlimited-disable": "error", + "eslint-comments/no-unused-disable": "error", + "@typescript-eslint/no-explicit-any": "error", + "react-refresh/only-export-components": [ + "error", + { allowConstantExport: true }, + ], + }, + }, + { + files: ["src/components/ui/**/*.{ts,tsx}"], + rules: { + "react-refresh/only-export-components": "off", + "react-hooks/incompatible-library": "off" + } + } +]); diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000000..e11675168f --- /dev/null +++ b/admin/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + BookMyVenue - Admin + + +
+ + + diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000000..ba6f35a2f3 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,65 @@ +{ + "name": "admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint .", + "lint:fix": "eslint src/**/*.{ts,tsx} --fix", + "preview": "vite preview", + "format": "prettier --write src/**/*.{js,jsx,ts,tsx}" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.17", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tooltip": "^1.2.10", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.17.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.4.0", + "lucide-react": "^1.21.0", + "prettier": "3.8.3", + "radix-ui": "^1.6.0", + "react": "^19.2.6", + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.6", + "react-hot-toast": "^2.6.0", + "react-router": "^7.16.0", + "recharts": "^3.9.0", + "tailwind-merge": "^3.6.0", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", + "@eslint/js": "^10.0.1", + "@rolldown/plugin-babel": "^0.2.3", + "@tailwindcss/vite": "^4.3.0", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/babel__core": "^7.20.5", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "jsdom": "^29.1.1", + "tailwindcss": "^4.3.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.10" + } +} diff --git a/admin/pnpm-lock.yaml b/admin/pnpm-lock.yaml new file mode 100644 index 0000000000..54e1de01ce --- /dev/null +++ b/admin/pnpm-lock.yaml @@ -0,0 +1,1788 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + devDependencies: + '@babel/core': + specifier: ^7.29.0 + version: 7.29.7 + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.1) + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)) + '@types/babel__core': + specifier: ^7.20.5 + version: 7.20.5 + '@types/node': + specifier: ^24.12.3 + version: 24.12.4 + '@types/react': + specifier: ^19.2.14 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.16) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.16(@types/node@24.12.4)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + eslint: + specifier: ^10.3.0 + version: 10.4.1 + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.4.1) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.4.1) + globals: + specifier: ^17.6.0 + version: 17.6.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.60.1(eslint@10.4.1)(typescript@6.0.3) + vite: + specifier: ^8.0.12 + version: 8.0.16(@types/node@24.12.4) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@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/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@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/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.16': + resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} + + '@typescript-eslint/eslint-plugin@8.60.1': + resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.60.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.60.1': + resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.60.1': + resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.60.1': + resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.60.1': + resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.60.1': + resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.60.1': + resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.60.1': + resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.60.1': + resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.60.1': + resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.33: + resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.364: + resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + 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-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.4.1: + resolution: {integrity: sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + 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'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + 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==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + 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.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.60.1: + resolution: {integrity: sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + 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==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.1)': + dependencies: + eslint: 10.4.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.4.1)': + optionalDependencies: + eslint: 10.4.1 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4))': + dependencies: + '@babel/core': 7.29.7 + picomatch: 4.0.4 + rolldown: 1.0.3 + optionalDependencies: + vite: 8.0.16(@types/node@24.12.4) + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.16)': + dependencies: + '@types/react': 19.2.16 + + '@types/react@19.2.16': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/type-utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.60.1 + eslint: 10.4.1 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.60.1 + debug: 4.4.3 + eslint: 10.4.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.60.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3) + '@typescript-eslint/types': 8.60.1 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.60.1': + dependencies: + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/visitor-keys': 8.60.1 + + '@typescript-eslint/tsconfig-utils@8.60.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.60.1(eslint@10.4.1)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.4.1 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.60.1': {} + + '@typescript-eslint/typescript-estree@8.60.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.60.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3) + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/visitor-keys': 8.60.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.60.1(eslint@10.4.1)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1) + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + eslint: 10.4.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.60.1': + dependencies: + '@typescript-eslint/types': 8.60.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.16(@types/node@24.12.4))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.16(@types/node@24.12.4) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)) + babel-plugin-react-compiler: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + 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 + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.7 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.33: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.33 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.364 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-lite@1.0.30001793: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.364: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.4.1): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.4.1 + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.2(eslint@10.4.1): + dependencies: + eslint: 10.4.1 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.4.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.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: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.6.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.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 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.46: {} + + 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@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.60.1(eslint@10.4.1)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + eslint: 10.4.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.0.16(@types/node@24.12.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/admin/public/apple-touch-icon.png b/admin/public/apple-touch-icon.png new file mode 100644 index 0000000000..2000537371 Binary files /dev/null and b/admin/public/apple-touch-icon.png differ diff --git a/admin/public/favicon-96x96.png b/admin/public/favicon-96x96.png new file mode 100644 index 0000000000..9e83e2a631 Binary files /dev/null and b/admin/public/favicon-96x96.png differ diff --git a/admin/public/favicon.ico b/admin/public/favicon.ico new file mode 100644 index 0000000000..ea259ee47b Binary files /dev/null and b/admin/public/favicon.ico differ diff --git a/admin/public/favicon.svg b/admin/public/favicon.svg new file mode 100644 index 0000000000..cc652d1204 --- /dev/null +++ b/admin/public/favicon.svg @@ -0,0 +1 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/admin/public/icons.svg b/admin/public/icons.svg new file mode 100644 index 0000000000..e9522193d9 --- /dev/null +++ b/admin/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/site.webmanifest b/admin/public/site.webmanifest new file mode 100644 index 0000000000..b55d1951c9 --- /dev/null +++ b/admin/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "BookMyVenue", + "short_name": "BMV", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/admin/public/web-app-manifest-192x192.png b/admin/public/web-app-manifest-192x192.png new file mode 100644 index 0000000000..86806ded66 Binary files /dev/null and b/admin/public/web-app-manifest-192x192.png differ diff --git a/admin/public/web-app-manifest-512x512.png b/admin/public/web-app-manifest-512x512.png new file mode 100644 index 0000000000..25386ddb5e Binary files /dev/null and b/admin/public/web-app-manifest-512x512.png differ diff --git a/admin/src/App.css b/admin/src/App.css new file mode 100644 index 0000000000..f90339d8f7 --- /dev/null +++ b/admin/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/admin/src/App.tsx b/admin/src/App.tsx new file mode 100644 index 0000000000..9e9d646b2a --- /dev/null +++ b/admin/src/App.tsx @@ -0,0 +1,24 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./config/queryClient"; +import { AppRouter } from "./router"; +import { TooltipProvider } from "./components/ui/tooltip"; +import { Toaster } from "react-hot-toast"; + +function App() { + return ( + + + + + + + ); +} + +export default App; diff --git a/admin/src/assets/bmv-logo.png b/admin/src/assets/bmv-logo.png new file mode 100644 index 0000000000..e66c1f6c21 Binary files /dev/null and b/admin/src/assets/bmv-logo.png differ diff --git a/admin/src/assets/hero.png b/admin/src/assets/hero.png new file mode 100644 index 0000000000..02251f4b95 Binary files /dev/null and b/admin/src/assets/hero.png differ diff --git a/admin/src/assets/react.svg b/admin/src/assets/react.svg new file mode 100644 index 0000000000..6c87de9bb3 --- /dev/null +++ b/admin/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/src/assets/vite.svg b/admin/src/assets/vite.svg new file mode 100644 index 0000000000..5101b674df --- /dev/null +++ b/admin/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/admin/src/components/bookings/OfflineBookingDialog.tsx b/admin/src/components/bookings/OfflineBookingDialog.tsx new file mode 100644 index 0000000000..845cd75727 --- /dev/null +++ b/admin/src/components/bookings/OfflineBookingDialog.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import type { OfflineForm } from "@/types/ui"; + +export function OfflineBookingDialog({ + offlineOpen, + setOfflineOpen, + form, + setForm, + handleSubmit, + isPending, + EMPTY_FORM, +}: { + offlineOpen: boolean; + setOfflineOpen: (open: boolean) => void; + form: OfflineForm; + setForm: React.Dispatch>; + handleSubmit: () => void; + isPending: boolean; + EMPTY_FORM: OfflineForm; +}) { + const set = + (field: keyof OfflineForm) => (e: React.ChangeEvent) => + setForm((prev) => ({ ...prev, [field]: e.target.value })); + + return ( + + + + New Offline Booking + + Record a cash or walk-in booking directly without an online payment. + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + +
+
+ ); +} diff --git a/admin/src/components/bookings/useBookingColumns.tsx b/admin/src/components/bookings/useBookingColumns.tsx new file mode 100644 index 0000000000..50c5dbfcc8 --- /dev/null +++ b/admin/src/components/bookings/useBookingColumns.tsx @@ -0,0 +1,183 @@ +import { Badge } from "@/components/ui/badge"; +import { BookingDetailPanel } from "@/components/common/panels/BookingDetailPanel"; +import { STATUS_VARIANT } from "@/constants/statusVariants"; +import { VenueDetailPanel } from "@/components/common/panels/VenueDetailPanel"; +import { UserDetailPanel } from "@/components/common/panels/UserDetailPanel"; +import { useApiQuery } from "@/hooks/useApi"; +import { QUERY_KEYS } from "@/config/queryKeys"; +import { API_ENDPOINTS } from "@/constants"; +import { PROFILE_STALE_TIME } from "@/constants/queryConfig"; +import { ROLES } from "@/constants/roles"; + +import type { Booking } from "@/types/models"; +import type { UserProfile } from "@/components/guards/AuthGuard"; + +import { type ActiveModal } from "@/store/useModalStore"; + +export function useBookingColumns({ + openModal, +}: { + openModal: (opts: Omit) => void; +}) { + const { data: profile } = useApiQuery( + QUERY_KEYS.PROFILE, + { method: "GET", url: API_ENDPOINTS.PROFILE }, + { staleTime: PROFILE_STALE_TIME }, + ); + + const isAdmin = + profile?.role === ROLES.ADMIN || profile?.role === ROLES.SUPER_ADMIN; + + const getDisplayStatus = (booking: Booking) => booking.uiStatus; + + const getStatusVariant = (status: string) => { + const statusKey = status.toUpperCase(); + return STATUS_VARIANT[statusKey] ?? "outline"; + }; + + return [ + { + accessorKey: "_id", + header: "Booking ID", + cell: ({ row }: { row: { original: Booking } }) => { + const id: string = row.original._id; + return ( + { + openModal({ + title: `Booking Details`, + size: "xl", + component: BookingDetailPanel, + data: { ...row.original, userRole: "admin" }, + actions: [], + }); + }} + > + BMV-{id.slice(-6).toUpperCase()} + + ); + }, + }, + { + accessorKey: "venueId", + header: "Venue", + cell: ({ row }: { row: { original: Booking } }) => ( + { + if (row.original.venue?._id) { + openModal({ + title: `Venue Details`, + size: "xl", + component: VenueDetailPanel, + data: row.original.venue, + actions: [], + }); + } + }} + > + {row.original.venue?.name ?? "—"} + + ), + }, + { + accessorKey: "userId", + header: "User Account", + headerClassName: isAdmin ? "" : "hidden", + cell: ({ row }: { row: { original: Booking } }) => { + if (!isAdmin) return ; + const user = row.original.user; + if (!user) return ; + return ( + { + openModal({ + title: `User Details`, + size: "lg", + component: UserDetailPanel, + data: user, + actions: [], + }); + }} + > + {user.username} ({user.email}) + + ); + }, + }, + { + accessorKey: "bookerName", + header: "Booked By", + cell: ({ row }: { row: { original: Booking } }) => { + return ( +
+

+ {row.original.bookerName ?? "—"} +

+

+ {row.original.bookerPhone ?? "—"} +

+ {row.original.bookerEmail && ( +

+ {row.original.bookerEmail} +

+ )} +
+ ); + }, + }, + { + accessorKey: "date", + header: "Event Date", + cell: ({ row }: { row: { original: Booking } }) => + new Date(row.original.date).toLocaleDateString(), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }: { row: { original: Booking } }) => { + const displayStatus = getDisplayStatus(row.original) ?? "confirmed"; + return ( + + {displayStatus.toUpperCase()} + + ); + }, + }, + { + accessorKey: "totalPrice", + header: "Amount", + cell: ({ row }: { row: { original: Booking } }) => ( + + ₹ + {row.original.price != null + ? (row.original.price as number).toLocaleString("en-IN") + : "0"} + + ), + }, + { + accessorKey: "paymentStatus", + header: "Payment", + cell: ({ row }: { row: { original: Booking } }) => ( + + {(row.original.paymentStatus as string)?.toUpperCase() || "—"} + + ), + }, + { + accessorKey: "createdAt", + header: "Booked On", + cell: ({ row }: { row: { original: Booking } }) => + new Date(row.original.createdAt).toLocaleDateString(), + }, + ]; +} diff --git a/admin/src/components/bookings/useOwnerBookingColumns.tsx b/admin/src/components/bookings/useOwnerBookingColumns.tsx new file mode 100644 index 0000000000..03e861064a --- /dev/null +++ b/admin/src/components/bookings/useOwnerBookingColumns.tsx @@ -0,0 +1,167 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@/components/ui/badge"; +import type { Booking } from "@/types/models"; +import { STATUS_VARIANT } from "@/constants/statusVariants"; +import { minutesToTime } from "@/utils/bookingUtils"; +import { type ActiveModal } from "@/store/useModalStore"; +import { BookingDetailPanel } from "@/components/common/panels/BookingDetailPanel"; + +export function useOwnerBookingColumns({ + openModal, + onMarkPaid, + onCancelPending, +}: { + openModal: (opts: Omit) => void; + onMarkPaid?: (bookingId: string) => void; + onCancelPending?: (bookingId: string) => void; +}): ColumnDef[] { + return [ + { + accessorKey: "_id", + header: "Booking ID", + cell: ({ row }) => { + const id: string = row.original._id; + return ( + + openModal({ + title: `Booking Details`, + size: "xl", + component: BookingDetailPanel, + data: { ...row.original, userRole: "owner" }, + actions: [], + }) + } + > + + BMV-{id.slice(-6).toUpperCase()} + {row.original.paymentMethod === "offline" && ( + + Offline + + )} + + + ); + }, + }, + { + accessorKey: "date", + header: "Date", + cell: ({ row }) => ( +
+

{row.original.date}

+

+ {minutesToTime(row.original.startTime)} —{" "} + {minutesToTime(row.original.endTime)} +

+
+ ), + }, + { + accessorKey: "bookerName", + header: "Customer", + cell: ({ row }) => ( +
+ openModal({ + title: `Booking Details`, + size: "xl", + component: BookingDetailPanel, + data: { ...row.original, userRole: "owner" }, + actions: [], + }) + } + > +

{row.original.bookerName ?? "—"}

+

+ {row.original.bookerPhone ?? ""} +

+ {row.original.bookerEmail && ( +

+ {row.original.bookerEmail} +

+ )} +
+ ), + }, + { + accessorKey: "totalPrice", + header: "Amount", + cell: ({ row }) => ( + + ₹{(row.original.price as number)?.toLocaleString("en-IN") ?? 0} + + ), + }, + { + accessorKey: "paymentMethod", + header: "Method", + cell: ({ row }) => ( + + {(row.original.paymentMethod as string) ?? "online"} + + ), + }, + { + accessorKey: "paymentStatus", + header: "Payment", + cell: ({ row }) => ( + + {(row.original.paymentStatus as string)?.toUpperCase() || "—"} + + ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const displayStatus = row.original.uiStatus ?? row.original.status; + return ( + + {(displayStatus as string).toUpperCase()} + + ); + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const paymentStatus = row.original.paymentStatus as string; + if (paymentStatus === "pending") { + return ( +
+ + +
+ ); + } + return null; + }, + }, + ]; +} diff --git a/admin/src/components/calendar/BlockDateDialog.tsx b/admin/src/components/calendar/BlockDateDialog.tsx new file mode 100644 index 0000000000..804ca28739 --- /dev/null +++ b/admin/src/components/calendar/BlockDateDialog.tsx @@ -0,0 +1,90 @@ +import { Lock } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +export function BlockDateDialog({ + confirmOpen, + setConfirmOpen, + selectedDate, + setSelectedDate, + isCurrentlyBlocked, + handleConfirmAction, + blockPending, + unblockPending, +}: { + confirmOpen: boolean; + setConfirmOpen: (open: boolean) => void; + selectedDate: Date | null; + setSelectedDate: (date: Date | null) => void; + isCurrentlyBlocked: boolean; + handleConfirmAction: () => void; + blockPending: boolean; + unblockPending: boolean; +}) { + return ( + { + setConfirmOpen(o); + if (!o) setSelectedDate(null); + }} + > + + + + + {isCurrentlyBlocked ? "Unblock Date" : "Block Date"} + + + Are you sure you want to {isCurrentlyBlocked ? "unblock" : "block"}{" "} + + {selectedDate + ? selectedDate.toLocaleDateString("en-IN", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }) + : ""} + + ?{" "} + {isCurrentlyBlocked + ? "Customers will be able to book on this date again." + : "Customers will not be able to book on this date."} + + + + + + + + + ); +} diff --git a/admin/src/components/calendar/BlockedDatesTable.tsx b/admin/src/components/calendar/BlockedDatesTable.tsx new file mode 100644 index 0000000000..05684b26d2 --- /dev/null +++ b/admin/src/components/calendar/BlockedDatesTable.tsx @@ -0,0 +1,91 @@ +import { Unlock } from "lucide-react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; + +import type { BlockedDate } from "@/types/models"; +import type { UseMutationResult } from "@tanstack/react-query"; + +export function BlockedDatesTable({ + tablePage, + setTablePage, + paginatedData, + totalPages, + isLoading, + unblockMutation, +}: { + tablePage: number; + setTablePage: (page: number) => void; + + paginatedData: BlockedDate[]; + totalPages: number; + isLoading: boolean; + + unblockMutation: UseMutationResult< + unknown, + unknown, + { venueId: string; dates: string[] }, + unknown + >; +}) { + const columns: ColumnDef[] = [ + { + accessorKey: "dateObj", + header: "Date", + cell: ({ row }) => + row.original.dateObj.toLocaleDateString("en-IN", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }), + }, + { + accessorKey: "isPast", + header: "Status", + cell: ({ row }) => + row.original.isPast ? ( + Past + ) : ( + + Upcoming + + ), + }, + { + id: "actions", + cell: ({ row }) => ( + + ), + }, + ]; + + return ( +
+ +
+ ); +} diff --git a/admin/src/components/calendar/CalendarLegend.tsx b/admin/src/components/calendar/CalendarLegend.tsx new file mode 100644 index 0000000000..dbc6b8cd78 --- /dev/null +++ b/admin/src/components/calendar/CalendarLegend.tsx @@ -0,0 +1,45 @@ +export function CalendarLegend({ + hasTempBlock, + hasInactivityBlock, +}: { + hasTempBlock?: boolean; + hasInactivityBlock?: boolean; +}) { + return ( +
+ {[ + { color: "bg-zinc-200", label: "Past / Unavailable" }, + { + color: "bg-blue-100 border border-blue-300", + label: "Booked by customer", + }, + { + color: "bg-red-100 border border-red-300", + label: "Blocked by you", + }, + ...(hasTempBlock + ? [ + { + color: "bg-amber-100 border border-amber-300", + label: "Temporarily blocked", + }, + ] + : []), + ...(hasInactivityBlock + ? [ + { + color: "bg-purple-100 border border-purple-300", + label: "Inactive period", + }, + ] + : []), + { color: "bg-white border border-zinc-300", label: "Available" }, + ].map(({ color, label }) => ( +
+
+ {label} +
+ ))} +
+ ); +} diff --git a/admin/src/components/calendar/VenueCalendar.tsx b/admin/src/components/calendar/VenueCalendar.tsx new file mode 100644 index 0000000000..cf513e5d4b --- /dev/null +++ b/admin/src/components/calendar/VenueCalendar.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { DayPicker } from "react-day-picker"; +import "react-day-picker/style.css"; + +export function VenueCalendar({ + isLoading, + handleDayClick, + isDisabledDay, + isBooked, + isBlocked, + isTempBlocked, + isInactivityBlocked, + isPast, + isTooFar, + isNonWorkingDay, +}: { + isLoading: boolean; + handleDayClick: (date: Date) => void; + isDisabledDay: (date: Date) => boolean; + isBooked: (date: Date) => boolean; + isBlocked: (date: Date) => boolean; + isTempBlocked: (date: Date) => boolean; + isInactivityBlocked: (date: Date) => boolean; + isPast: (date: Date) => boolean; + isTooFar: (date: Date) => boolean; + isNonWorkingDay: (date: Date) => boolean; +}) { + return ( + <> +
+ {isLoading ? ( +
+ ) : ( +
+ isBooked(date), + blocked: (date) => isBlocked(date), + tempBlocked: (date) => isTempBlocked(date), + inactivityBlocked: (date) => isInactivityBlocked(date), + unavailable: (date) => + isPast(date) || isTooFar(date) || isNonWorkingDay(date), + }} + modifiersClassNames={{ + booked: "rdp-day--booked", + blocked: "rdp-day--blocked", + tempBlocked: "rdp-day--temp-blocked", + inactivityBlocked: "rdp-day--inactivity-blocked", + unavailable: "rdp-day--unavailable", + }} + styles={{ + root: { + "--rdp-accent-color": "#18181b", + } as React.CSSProperties, + }} + /> +
+ )} +
+ + + ); +} diff --git a/admin/src/components/common/CustomTooltip.tsx b/admin/src/components/common/CustomTooltip.tsx new file mode 100644 index 0000000000..007be338e4 --- /dev/null +++ b/admin/src/components/common/CustomTooltip.tsx @@ -0,0 +1,19 @@ +interface TooltipProps { + active?: boolean; + payload?: { value: number }[]; + label?: string; +} + +export function CustomTooltip({ active, payload, label }: TooltipProps) { + if (active && payload && payload.length) { + return ( +
+

{label}

+

+ ₹{payload[0].value.toLocaleString("en-IN")} +

+
+ ); + } + return null; +} diff --git a/admin/src/components/common/Footer.tsx b/admin/src/components/common/Footer.tsx new file mode 100644 index 0000000000..4585589ab1 --- /dev/null +++ b/admin/src/components/common/Footer.tsx @@ -0,0 +1 @@ +// mt diff --git a/admin/src/components/common/MetricCard.tsx b/admin/src/components/common/MetricCard.tsx new file mode 100644 index 0000000000..b6e97d1a8a --- /dev/null +++ b/admin/src/components/common/MetricCard.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +export function MetricCard({ + title, + value, + subtitle, + icon: Icon, + accent, +}: { + title: string; + value: string; + subtitle: string; + icon: React.ElementType; + accent: string; +}) { + return ( +
+
+
+

{title}

+

+ {value} +

+

{subtitle}

+
+
+ +
+
+
+ ); +} diff --git a/admin/src/components/common/ModalRoot.tsx b/admin/src/components/common/ModalRoot.tsx new file mode 100644 index 0000000000..49edb75e74 --- /dev/null +++ b/admin/src/components/common/ModalRoot.tsx @@ -0,0 +1,68 @@ +import { useModalStore } from "@/store/useModalStore"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; + +const SIZE_CLASS = { + sm: "sm:max-w-sm", + md: "sm:max-w-md", + lg: "sm:max-w-2xl", + xl: "sm:max-w-4xl", + "2xl": "sm:max-w-6xl", +}; + +export function ModalRoot() { + const { modal, close } = useModalStore(); + + if (!modal) return null; + + const Component = modal.component; + + return ( + !open && close()}> + + + {modal.title} + +
+ +
+ {modal.actions.length > 0 && ( + + + {modal.actions.map((action, i) => ( + + ))} + + )} +
+
+ ); +} diff --git a/admin/src/components/common/Navbar.tsx b/admin/src/components/common/Navbar.tsx new file mode 100644 index 0000000000..4585589ab1 --- /dev/null +++ b/admin/src/components/common/Navbar.tsx @@ -0,0 +1 @@ +// mt diff --git a/admin/src/components/common/StarRating.tsx b/admin/src/components/common/StarRating.tsx new file mode 100644 index 0000000000..34739d8a1a --- /dev/null +++ b/admin/src/components/common/StarRating.tsx @@ -0,0 +1,16 @@ +export function StarRating({ rating }: { rating: number }) { + return ( +
+ {[...Array(5)].map((_, i) => ( + + ★ + + ))} +
+ ); +} diff --git a/admin/src/components/common/panels/BookingDetailPanel.tsx b/admin/src/components/common/panels/BookingDetailPanel.tsx new file mode 100644 index 0000000000..7a4446a54a --- /dev/null +++ b/admin/src/components/common/panels/BookingDetailPanel.tsx @@ -0,0 +1,302 @@ +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { BOOKING_STATUS_COLORS } from "@/constants/bookings"; +import { STATUS_VARIANT } from "@/constants/statusVariants"; +import { minutesToTime } from "@/utils/bookingUtils"; +import { cn } from "@/lib/utils"; +import { + Clock, + Calendar as CalendarIcon, + MapPin, + IndianRupee, + User, + FileText, +} from "lucide-react"; + +interface BookingData { + status: string; + duration?: number; + venueId?: string; + date?: string; + startTime?: number; + endTime?: number; + amountPaid?: number; + platformFee?: number; + taxAmount?: number; + contactNumber?: string; + customerName?: string; + guests?: number; + paymentReference?: string; + createdAt?: string; + _id?: string; + cancellationReason?: string; + eventType?: string; + price?: number; + paymentMethod?: string; + paymentStatus?: string; + userId?: string; + user?: { _id?: string; username?: string; email?: string; phone?: string }; + bookerName?: string; + bookerEmail?: string; + bookerPhone?: string; + bookerInfo?: { name?: string; email?: string; phone?: string }; + venue?: { _id?: string; name?: string; city?: string; address?: string }; + [key: string]: unknown; +} +export function BookingDetailPanel({ + data: rawData, +}: { + data: Record; +}) { + const userRole = (rawData as Record).userRole as + | string + | undefined; + const isOwner = userRole === "owner"; + const data = rawData as unknown as BookingData; + if (!data) return null; + + const statusColor = + BOOKING_STATUS_COLORS[data.status as keyof typeof BOOKING_STATUS_COLORS] ?? + "bg-gray-500/10 text-gray-500"; + + return ( +
+ {/* Header Banner */} +
+
+

+ Booking{" "} + + #{data._id?.slice(-6).toUpperCase()} + +

+
+ + {data.createdAt ? new Date(data.createdAt).toLocaleString() : "N/A"} +
+
+ + {data.status} + +
+ +
+ {/* Booking Schedule */} + +

+ Schedule & Timing +

+
+
+ + Date + + + {data.date ? new Date(data.date).toLocaleDateString() : "N/A"} + +
+
+ + Time Slots + + + {data.startTime !== undefined && data.endTime !== undefined + ? `${minutesToTime(data.startTime)} - ${minutesToTime(data.endTime)}` + : "N/A"} + +
+ {data.eventType && ( +
+ + Type + + + {data.eventType} + +
+ )} +
+
+ + {/* Payment Info */} + +

+ Payment Details +

+
+
+ + Total Amount + + + ₹{data.price} + +
+
+ + Method + + + {data.paymentMethod || "N/A"} + +
+
+ + Payment Status + + + {(data.paymentStatus as string)?.toUpperCase() || "—"} + +
+ {data.paymentReference && ( +
+ + Transaction ID + + + {data.paymentReference} + +
+ )} +
+
+ + {/* Venue Info */} + +

+ Venue Information +

+
+
+ + Venue Name + + + {data.venue?.name || "N/A"} + +
+ {(data.venue?.city || data.venue?.address) && ( +
+ + Location + + + {data.venue?.address}, {data.venue?.city} + +
+ )} + {!isOwner && ( +
+ + Venue ID + + + {data.venue?.name + ? `${data.venue.name} — ${data.venue?._id || data.venueId}` + : data.venue?._id || data.venueId} + +
+ )} +
+
+ + {/* Booker Info */} + +

+ Customer Details +

+
+
+ + Customer Name + + + {(data.bookerName as string) || data.bookerInfo?.name || "N/A"} + +
+ {((data.bookerEmail as string) || + (data.bookerPhone as string) || + data.bookerInfo?.email || + data.bookerInfo?.phone) && ( +
+
+ + Email + + + {(data.bookerEmail as string) || + data.bookerInfo?.email || + "N/A"} + +
+
+ + Phone + + + {(data.bookerPhone as string) || + data.bookerInfo?.phone || + "N/A"} + +
+
+ )} + {isOwner && data.user && ( +
+ Booked by{" "} + + {(data.user as Record)?.username as string} + +
+ )} + {!isOwner && ( +
+ + User ID + + + {data.user + ? `${(data.user as Record).username as string} (${(data.user as Record).email as string}) — ${data.userId}` + : data.userId} + +
+ )} +
+
+
+ + {/* Cancellation / Refund Info */} + {(data.status === "CANCELLED" || data.status === "REFUNDED") && ( + +

+ Cancellation & Refund +

+
+ {data.cancellationReason && ( +
+ + Reason + + + {data.cancellationReason} + +
+ )} +
+
+ )} +
+ ); +} diff --git a/admin/src/components/common/panels/FeaturedVenuesPanel.tsx b/admin/src/components/common/panels/FeaturedVenuesPanel.tsx new file mode 100644 index 0000000000..b2a83ce585 --- /dev/null +++ b/admin/src/components/common/panels/FeaturedVenuesPanel.tsx @@ -0,0 +1,168 @@ +import { useApiQuery, useApiMutation } from "@/hooks/useApi"; +import { API_ENDPOINTS } from "@/constants"; +import { QUERY_KEYS } from "@/config/queryKeys"; +import { useQueryClient } from "@tanstack/react-query"; +import { useToast } from "@/hooks/useToast"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, AlertCircle, XCircle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { differenceInDays, differenceInHours } from "date-fns"; + +interface Venue { + _id: string; + name: string; + city: string; + venueType: string; + status: string; + featuredExpiresAt?: string | null; +} + +const getTimeLeft = (expiresAt?: string | null) => { + if (!expiresAt) return "Indefinite"; + const now = new Date(); + const expiryDate = new Date(expiresAt); + if (expiryDate < now) return "Expired"; + + const daysLeft = differenceInDays(expiryDate, now); + if (daysLeft > 0) return `${daysLeft} days left`; + + const hoursLeft = differenceInHours(expiryDate, now); + return `${hoursLeft} hours left`; +}; + +export const FeaturedVenuesPanel = () => { + const queryClient = useQueryClient(); + const { success, error } = useToast(); + + const { + data: venues, + isLoading, + isError, + } = useApiQuery([...QUERY_KEYS.ADMIN_VENUES, "featured"], { + method: "GET", + url: API_ENDPOINTS.FEATURED_VENUES, + }); + + const unfeatureMutation = useApiMutation( + (vars) => ({ + method: "DELETE", + url: `${API_ENDPOINTS.VENUES}/${vars.id}/feature`, + }), + { + onSuccess: () => { + success("Venue removed from featured list"); + queryClient.invalidateQueries({ + queryKey: [...QUERY_KEYS.ADMIN_VENUES, "featured"], + }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.ADMIN_VENUES }); + }, + onError: (e: Error) => { + const err = e as import("axios").AxiosError<{ message: string }>; + error(err.response?.data?.message ?? "Failed to un-feature venue"); + }, + }, + ); + + if (isLoading) { + return ( +
+ +

Loading featured venues...

+
+ ); + } + + if (isError) { + return ( +
+ + + Error + + {error instanceof Error + ? error.message + : "Failed to load featured venues"} + + +
+ ); + } + + if (!venues || venues.length === 0) { + return ( +
+
+ +
+

No Featured Venues

+

+ You haven't featured any venues yet. You can feature venues from the + main venues table. +

+
+ ); + } + + return ( +
+
+ {venues.map((venue) => { + const timeLeft = getTimeLeft(venue.featuredExpiresAt); + const isExpired = timeLeft === "Expired"; + + return ( +
+
+

{venue.name}

+
+ + {venue.city} + + + + {venue.venueType} + +
+
+ +
+
+ + Featured + + + {timeLeft} + +
+ +
+
+ ); + })} +
+
+ ); +}; diff --git a/admin/src/components/common/panels/OwnerDetailPanel.tsx b/admin/src/components/common/panels/OwnerDetailPanel.tsx new file mode 100644 index 0000000000..28aa9d9511 --- /dev/null +++ b/admin/src/components/common/panels/OwnerDetailPanel.tsx @@ -0,0 +1,178 @@ +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { User, Mail, Phone, Calendar, Store, ShieldCheck } from "lucide-react"; + +interface OwnerData { + username?: string; + name?: string; + email?: string; + phone?: string; + active?: boolean; + _id?: string; + role?: string; + createdAt?: string; + updatedAt?: string; + venues?: { + name?: string; + city?: string; + address?: string; + status?: string; + }[]; + [key: string]: unknown; +} +export function OwnerDetailPanel({ + data: rawData, +}: { + data: Record; +}) { + const data = rawData as unknown as OwnerData; + if (!data) return null; + + return ( +
+ {/* Header Profile Section */} +
+
+ +
+
+

+ {data.username || data.name} +

+
+ + {data.email} + + {data.phone && ( + + {data.phone} + + )} +
+
+
+ + {data.active ? "Active Owner" : "Inactive Account"} + +
+ Verified Partner +
+
+
+ +
+ {/* Venues Overview */} + +
+

+ Registered Venues +

+ {data.venues && ( + + {data.venues.length} Total + + )} +
+ +
+ {data.venues && data.venues.length > 0 ? ( +
+ {data.venues.map((venue, idx) => ( +
+
+ + {venue.name} + + + {venue.city || venue.address} + +
+ + {venue.status} + +
+ ))} +
+ ) : ( +
+ +

No venues registered yet

+
+ )} +
+
+
+ +
+ {/* Account Info */} + +

+ Account Details +

+
+
+ + User ID + + + {data._id} + +
+
+ + Role Type + + + {data.role || "Owner"} + +
+
+
+ + {/* Audit */} + +

+ Timestamps +

+
+
+ + Joined Date + + + {data.createdAt + ? new Date(data.createdAt).toLocaleDateString() + : "N/A"} + +
+
+ + Last Updated + + + {data.updatedAt + ? new Date(data.updatedAt).toLocaleDateString() + : "N/A"} + +
+
+
+
+
+ ); +} diff --git a/admin/src/components/common/panels/UserDetailPanel.tsx b/admin/src/components/common/panels/UserDetailPanel.tsx new file mode 100644 index 0000000000..42ecc87039 --- /dev/null +++ b/admin/src/components/common/panels/UserDetailPanel.tsx @@ -0,0 +1,137 @@ +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Mail, User, Shield, CalendarDays, Phone } from "lucide-react"; + +interface UserData { + _id: string; + username: string; + email: string; + phone?: string; + status: string; + role?: string; + createdAt: string; +} + +export function UserDetailPanel({ + data: rawData, +}: { + data: Record; +}) { + const data = rawData as unknown as UserData; + if (!data) return null; + + const statusVariant = + data.status === "active" + ? "default" + : data.status === "suspended" + ? "destructive" + : "outline"; + + return ( +
+ {/* Header Banner */} +
+
+

+ + {data.username} +

+
+ + {data.email} +
+
+ + {data.status} + +
+ +
+ {/* Account Info */} + +

+ Account Details +

+
+
+ + User ID + + + {data._id} + +
+
+ + Username + + + {data.username} + +
+
+ + Role + + + {data.role || "user"} + +
+
+ + Status + + {data.status} +
+
+ + Registered + + + + {data.createdAt + ? new Date(data.createdAt).toLocaleDateString() + : "N/A"} + +
+
+ + Phone + + + + {data.phone || "N/A"} + +
+
+
+ + {/* Contact Info */} + +

+ Contact Information +

+
+
+ + Email + + {data.email} +
+ {data.phone && ( +
+ + Phone + + {data.phone} +
+ )} +
+
+
+
+ ); +} diff --git a/admin/src/components/common/panels/VenueDetailPanel.tsx b/admin/src/components/common/panels/VenueDetailPanel.tsx new file mode 100644 index 0000000000..d14fc6cfee --- /dev/null +++ b/admin/src/components/common/panels/VenueDetailPanel.tsx @@ -0,0 +1,641 @@ +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { RejectionHistoryDialog } from "@/components/venues/RejectionHistoryDialog"; +import { + CalendarDays, + Clock, + MapPin, + Phone, + Mail, + User, + Info, + Banknote, + Users, + CheckCircle2, + XCircle, + AlertCircle, + ChevronLeft, + ChevronRight, + X, +} from "lucide-react"; + +interface RejectionEntry { + reason: string; + rejectedAt: string; + rejectedBy?: string; + submissionNumber: number; + editDeadline: string; + extendedAt?: string; + extendedBy?: string; + originalDeadline?: string; +} + +interface VenueData { + status?: string; + suspensionReason?: string; + rejectionReason?: string; + rejectionHistory?: RejectionEntry[]; + submissionCount?: number; + active?: boolean; + name?: string; + venueType?: string; + bookingType?: string; + coverImage?: string; + galleryImages?: string[]; + capacity?: number; + maxCapacity?: number; + city?: string; + district?: string; + address?: string; + pincode?: string; + pricing?: { + pricingType?: string; + basePrice?: number; + pricePerHour?: number; + pricePerDay?: number; + pricingRules?: { fromTime: string; toTime: string; price: number }[]; + }; + fixedPackages?: { + name?: string; + slotName?: string; + startTime: string; + endTime: string; + price: number; + }[]; + amenities?: string[]; + description?: string; + contact?: { name: string; phone: string; email: string }; + workingDays?: string[]; + createdAt?: string; + updatedAt?: string; + lastSubmittedAt?: string; + currentEditDeadline?: string; + avgRating?: number; + reviewCount?: number; + [key: string]: unknown; +} + +export function VenueDetailPanel({ + data: rawData, +}: { + data: Record; +}) { + const [historyOpen, setHistoryOpen] = useState(false); + const [isDescExpanded, setIsDescExpanded] = useState(false); + const [galleryOpen, setGalleryOpen] = useState(false); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + + const data = rawData as unknown as VenueData; + if (!data) return null; + + const allImages = [data.coverImage, ...(data.galleryImages || [])].filter( + Boolean, + ) as string[]; + + const handleOpenGallery = (index: number) => { + setCurrentImageIndex(index); + setGalleryOpen(true); + }; + + const handlePrevImage = () => { + setCurrentImageIndex((prev) => + prev === 0 ? allImages.length - 1 : prev - 1, + ); + }; + + const handleNextImage = () => { + setCurrentImageIndex((prev) => + prev === allImages.length - 1 ? 0 : prev + 1, + ); + }; + + const pricingRules = data.pricing?.pricingRules; + const fixedPackages = data.fixedPackages; + + const hasStatusAlerts = + (data.status === "Rejected" && data.rejectionReason) || + (data.status === "Suspended" && data.suspensionReason); + + return ( +
+ {/* 1. Hero Cover Image */} + {data.coverImage && ( + + )} + + {/* 2. Gallery Strip */} + {data.galleryImages && data.galleryImages.length > 0 && ( +
+
+ + Gallery Photos{" "} + + {data.galleryImages.length} + + +
+
+ {data.galleryImages.map((url: string, i: number) => ( + + ))} +
+
+ )} + + {/* 3. Status Alerts */} + {hasStatusAlerts && ( +
+ {data.status === "Rejected" && data.rejectionReason && ( +
+ +
+

+ Rejection Reason +

+

+ {data.rejectionReason} +

+
+
+ )} + {data.status === "Suspended" && data.suspensionReason && ( +
+ +
+

+ Suspension Reason +

+

+ {data.suspensionReason} +

+
+
+ )} +
+ )} + + {/* 4. Rejection History Bar */} + {data.rejectionHistory && data.rejectionHistory.length > 0 && ( +
+
+ + {data.rejectionHistory.length} + + Previous Rejections +
+ +
+ )} + + {data.rejectionHistory && data.rejectionHistory.length > 0 && ( + + )} + + {/* 5. Two-column Info Grid */} +
+ {/* Core Details */} + +
+ +

Core Details

+
+
+
+
Type
+
+ {data.venueType || "—"} +
+
+
+
Booking Mode
+
+ {data.bookingType === "fixedBooking" + ? "Fixed Slots" + : "Flexible Time"} +
+
+ {data.workingDays && data.workingDays.length > 0 && ( +
+
Operating Days
+
+ {data.workingDays.map((day) => ( + + {day.slice(0, 3)} + + ))} +
+
+ )} +
+
+ + {/* Location */} + +
+ +

Location

+
+
+
+
City
+
{data.city || "—"}
+
+
+
District
+
{data.district || "—"}
+
+
+
PIN Code
+
{data.pincode || "—"}
+
+
+
+ Address +
+
+ {data.address || "—"} +
+
+
+
+
+ + {/* 6. Pricing & Packages */} + +
+ +

Pricing Structure

+
+ + {data.bookingType === "fixedBooking" && + fixedPackages && + fixedPackages.length > 0 ? ( +
+ {fixedPackages.map((pkg, i) => ( +
+
+ + {pkg.slotName || pkg.name || `Slot ${i + 1}`} + + + ₹{pkg.price} + +
+
+ + + {pkg.startTime} - {pkg.endTime} + +
+
+ ))} +
+ ) : ( +
+ {data.pricing && ( +
+
+

+ Pricing Type +

+

+ {data.pricing.pricingType || "—"} +

+
+
+

+ Base Price +

+

+ ₹{data.pricing.basePrice || "0"} +

+
+
+ )} + + {pricingRules && pricingRules.length > 0 && ( +
+ + + + + + + + + {pricingRules.map((rule, i) => ( + + + + + ))} + +
+ Time Range + + Price Modifier +
+ + {rule.fromTime} - {rule.toTime} + + ₹{rule.price} +
+
+ )} +
+ )} +
+ + {/* 7. Amenities & Contact */} +
+ +
+ +

Amenities

+
+
+ {data.amenities && data.amenities.length > 0 ? ( + data.amenities.map((am: string, i: number) => ( + + {am} + + )) + ) : ( + + No amenities listed + + )} +
+
+ + +
+ +

Primary Contact

+
+ {data.contact ? ( +
+
+
+ +
+ {data.contact.name} +
+ + +
+ ) : ( + + No contact details + + )} +
+
+ + {/* 8. Description */} + {data.description && ( + +

Description

+
+

+ {data.description} +

+ {data.description.length > 250 && ( + + )} +
+
+ )} + + {/* 9. Metadata Footer */} +
+ {data.createdAt && ( +
+ + + Created: {new Date(data.createdAt).toLocaleDateString()} + +
+ )} + {data.updatedAt && ( +
+ + + Updated: {new Date(data.updatedAt).toLocaleDateString()} + +
+ )} + {data.submissionCount !== undefined && ( +
+ + Submissions: {data.submissionCount}/10 +
+ )} + {data.avgRating !== undefined && ( +
+ + + {data.avgRating.toFixed(1)} + + ({data.reviewCount || 0} reviews) +
+ )} +
+ + {/* Lightbox Overlay */} + {galleryOpen && + typeof document !== "undefined" && + createPortal( +
{ + e.stopPropagation(); + setGalleryOpen(false); + }} + onPointerDown={(e) => e.stopPropagation()} + > +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + + +
+ {allImages.length > 0 && ( + {`Gallery + )} +
+ +
+ {currentImageIndex + 1} / {allImages.length} +
+ +
+ + +
+ {allImages.map((img, idx) => ( + + ))} +
+ + +
+
+
, + document.body, + )} +
+ ); +} diff --git a/admin/src/components/guards/AuthGuard.tsx b/admin/src/components/guards/AuthGuard.tsx new file mode 100644 index 0000000000..6dc341a7d4 --- /dev/null +++ b/admin/src/components/guards/AuthGuard.tsx @@ -0,0 +1,62 @@ +import { Navigate, Outlet, useLocation } from "react-router"; +import { Loader2 } from "lucide-react"; +import { useApiQuery } from "@/hooks/useApi"; +import { QUERY_KEYS } from "@/config/queryKeys"; +import { API_ENDPOINTS } from "@/constants"; +import { ROLES } from "@/constants/roles"; +import { PROFILE_STALE_TIME } from "@/constants/queryConfig"; + +export interface UserProfile { + _id: string; + name: string; + email: string; + role: "owner" | "admin" | "superAdmin"; + avatar?: string; + phone?: string; +} + +export function AuthGuard() { + const location = useLocation(); + const { + data: profile, + isLoading, + isError, + } = useApiQuery( + QUERY_KEYS.PROFILE, + { method: "GET", url: API_ENDPOINTS.PROFILE }, + { staleTime: PROFILE_STALE_TIME }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !profile) { + const redirectUrl = `${location.pathname}${location.search}`; + try { + localStorage.setItem("redirectUrl", redirectUrl); + } catch { + // Ignore localStorage errors + } + return ( + + ); + } + + if ( + profile.role !== ROLES.OWNER && + profile.role !== ROLES.ADMIN && + profile.role !== ROLES.SUPER_ADMIN + ) { + return ; + } + + return ; +} diff --git a/admin/src/components/guards/OwnerGuard.tsx b/admin/src/components/guards/OwnerGuard.tsx new file mode 100644 index 0000000000..70475355e8 --- /dev/null +++ b/admin/src/components/guards/OwnerGuard.tsx @@ -0,0 +1,23 @@ +import { Navigate, Outlet, useLocation } from "react-router"; +import { useApiQuery } from "@/hooks/useApi"; +import { QUERY_KEYS } from "@/config/queryKeys"; +import { API_ENDPOINTS } from "@/constants"; +import { ROLES } from "@/constants/roles"; +import { PROFILE_STALE_TIME } from "@/constants/queryConfig"; +import { ROUTES } from "@/constants/routes"; +import type { UserProfile } from "./AuthGuard"; + +export function OwnerGuard() { + const location = useLocation(); + const { data: profile } = useApiQuery( + QUERY_KEYS.PROFILE, + { method: "GET", url: API_ENDPOINTS.PROFILE }, + { staleTime: PROFILE_STALE_TIME }, + ); + + if (profile?.role === ROLES.OWNER && location.pathname === ROUTES.DASHBOARD) { + return ; + } + + return ; +} diff --git a/admin/src/components/guards/RoleGuard.tsx b/admin/src/components/guards/RoleGuard.tsx new file mode 100644 index 0000000000..339fea3e82 --- /dev/null +++ b/admin/src/components/guards/RoleGuard.tsx @@ -0,0 +1,28 @@ +import { Navigate, Outlet } from "react-router"; +import { useApiQuery } from "@/hooks/useApi"; +import { QUERY_KEYS } from "@/config/queryKeys"; +import { API_ENDPOINTS } from "@/constants"; +import { PROFILE_STALE_TIME } from "@/constants/queryConfig"; +import type { UserProfile } from "./AuthGuard"; + +interface RoleGuardProps { + allowedRoles: UserProfile["role"][]; +} + +export function RoleGuard({ allowedRoles }: RoleGuardProps) { + const { data: profile, isLoading } = useApiQuery( + QUERY_KEYS.PROFILE, + { method: "GET", url: API_ENDPOINTS.PROFILE }, + { staleTime: PROFILE_STALE_TIME }, + ); + + if (isLoading) { + return null; + } + + if (!profile || !allowedRoles.includes(profile.role)) { + return ; + } + + return ; +} diff --git a/admin/src/components/layout/MainLayout.tsx b/admin/src/components/layout/MainLayout.tsx new file mode 100644 index 0000000000..fa018a452a --- /dev/null +++ b/admin/src/components/layout/MainLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet } from "react-router"; +import { Sidebar } from "./Sidebar"; +import { ModalRoot } from "@/components/common/ModalRoot"; + +export function MainLayout() { + return ( +
+ +
+
+ +
+
+ +
+ ); +} diff --git a/admin/src/components/layout/Sidebar.tsx b/admin/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000000..8dfdf1f30e --- /dev/null +++ b/admin/src/components/layout/Sidebar.tsx @@ -0,0 +1,291 @@ +import { NavLink, useNavigate } from "react-router"; +import { + CalendarCheck, + Calendar, + BarChart2, + Building2, + Users, + Shield, + UserCog, + AlertTriangle, + LogOut, + Star, + ActivitySquare, + Settings, +} from "lucide-react"; +import bmvLogo from "@/assets/bmv-logo.png"; +import { useApiQuery } from "@/hooks/useApi"; +import { QUERY_KEYS } from "@/config/queryKeys"; +import { API_ENDPOINTS } from "@/constants"; +import { ROLES } from "@/constants/roles"; +import { VENUE_STATUS } from "@/constants/venueStatus"; +import { PROFILE_STALE_TIME } from "@/constants/queryConfig"; +import type { UserProfile } from "@/components/guards/AuthGuard"; +import { cn } from "@/lib/utils"; +import { axiosInstance } from "@/config/axios"; +import { queryClient } from "@/config/queryClient"; +import { Button } from "@/components/ui/button"; +import { useAppStore } from "@/store/useAppStore"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type NavItem = { + label: string; + path: string | ((venueId: string | null) => string); + icon: React.ElementType; + roles: UserProfile["role"][]; +}; + +interface MyVenue { + _id: string; + name: string; + status: string; +} + +interface MyVenuesResponse { + count: number; + venues: MyVenue[]; +} + +const NAV_CONFIG: NavItem[] = [ + // owner + { + label: "My Venues", + path: "/dashboard/select-venue", + icon: Building2, + roles: [ROLES.OWNER], + }, + { + label: "Bookings", + path: (venueId) => `/dashboard/venue/${venueId}/bookings`, + icon: CalendarCheck, + roles: [ROLES.OWNER], + }, + { + label: "Reviews", + path: (venueId) => `/dashboard/venue/${venueId}/reviews`, + icon: Star, + roles: [ROLES.OWNER], + }, + { + label: "Calendar", + path: (venueId) => `/dashboard/venue/${venueId}/calendar`, + icon: Calendar, + roles: [ROLES.OWNER], + }, + { + label: "Reports", + path: (venueId) => `/dashboard/venue/${venueId}/reports`, + icon: BarChart2, + roles: [ROLES.OWNER], + }, + { + label: "Settings", + path: (venueId) => `/dashboard/venue/${venueId}/settings`, + icon: Settings, + roles: [ROLES.OWNER], + }, + // admin + { + label: "Bookings", + path: "/dashboard/bookings", + icon: CalendarCheck, + roles: [ROLES.ADMIN, ROLES.SUPER_ADMIN], + }, + { + label: "Venues", + path: "/dashboard/venues", + icon: Building2, + roles: [ROLES.ADMIN, ROLES.SUPER_ADMIN], + }, + { + label: "Owners", + path: "/dashboard/owners", + icon: Users, + roles: [ROLES.ADMIN, ROLES.SUPER_ADMIN], + }, + { + label: "Moderation", + path: "/dashboard/moderation", + icon: AlertTriangle, + roles: [ROLES.ADMIN, ROLES.SUPER_ADMIN], + }, + // superAdmin only + { + label: "Team", + path: "/dashboard/team", + icon: Shield, + roles: [ROLES.SUPER_ADMIN], + }, + { + label: "Users", + path: "/dashboard/users", + icon: UserCog, + roles: [ROLES.SUPER_ADMIN], + }, + { + label: "Activity Logs", + path: "/dashboard/logs", + icon: ActivitySquare, + roles: [ROLES.SUPER_ADMIN], + }, +]; + +export function Sidebar() { + const navigate = useNavigate(); + const { activeVenueId, lastVenueSubRoute, setActiveVenue } = useAppStore(); + + const { data: profile } = useApiQuery( + QUERY_KEYS.PROFILE, + { method: "GET", url: API_ENDPOINTS.PROFILE }, + { staleTime: PROFILE_STALE_TIME }, + ); + + const { data: myVenuesData } = useApiQuery( + QUERY_KEYS.MY_VENUES, + { method: "GET", url: API_ENDPOINTS.MY_VENUES }, + { + staleTime: PROFILE_STALE_TIME, + enabled: profile?.role === ROLES.OWNER, + }, + ); + + const handleSignOut = async () => { + try { + await axiosInstance.post(API_ENDPOINTS.LOGOUT); + } catch { + // ignore logout errors on client + } finally { + queryClient.clear(); + navigate("/login"); + } + }; + + const navItems = NAV_CONFIG.filter( + (item) => profile && item.roles.includes(profile.role), + ); + + const venues = myVenuesData?.venues || []; + + return ( +
+ {/* Brand */} +
+ BookMyVenue + + Admin + +
+ + {/* Navigation Links */} + + + {/* Venue Switcher (Only for owners) */} + {profile?.role === ROLES.OWNER && ( +
+ +
+ )} + + {/* User Footer */} +
+
+
+ {profile?.name?.charAt(0).toUpperCase() || "U"} +
+
+

+ {profile?.name} +

+

+ {profile?.role} +

+
+ +
+
+
+ ); +} diff --git a/admin/src/components/layout/TenantLayout.tsx b/admin/src/components/layout/TenantLayout.tsx new file mode 100644 index 0000000000..820550f48b --- /dev/null +++ b/admin/src/components/layout/TenantLayout.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef } from "react"; +import { Outlet, useLocation, useNavigate, useParams } from "react-router"; +import { Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/useToast"; +import { useAppStore } from "@/store/useAppStore"; +import { useApiQuery } from "@/hooks/useApi"; +import { QUERY_KEYS } from "@/config/queryKeys"; +import { API_ENDPOINTS } from "@/constants"; +import { VENUE_STATUS } from "@/constants/venueStatus"; +import { PROFILE_STALE_TIME } from "@/constants/queryConfig"; + +interface MyVenue { + _id: string; + name: string; + city: string; + venueType: string; + coverImage: string; + status: + | "Draft" + | "PendingReview" + | "Approved" + | "Rejected" + | "Suspended" + | "Inactive"; + rejectionReason?: string; +} + +interface MyVenuesResponse { + count: number; + venues: MyVenue[]; +} + +export function TenantLayout() { + const { venueId } = useParams<{ venueId: string }>(); + const location = useLocation(); + const navigate = useNavigate(); + const { setActiveVenue, setLastVenueSubRoute } = useAppStore(); + const handledFailRef = useRef(null); + const { error } = useToast(); + + const venueSubRoute = location.pathname.split("/").pop() ?? "reports"; + + const { + data: myVenues, + isLoading, + isError, + } = useApiQuery( + QUERY_KEYS.MY_VENUES, + { method: "GET", url: API_ENDPOINTS.MY_VENUES }, + { staleTime: PROFILE_STALE_TIME }, + ); + + const venue = + venueId && myVenues + ? myVenues.venues.find((v) => v._id === venueId) + : undefined; + + const isInactive = venue?.status === VENUE_STATUS.INACTIVE; + + const isAccessDenied = + !isLoading && !isError && !!myVenues && !!venueId && !venue; + const isBlockedStatus = + venue && + venue.status !== VENUE_STATUS.APPROVED && + venue.status !== VENUE_STATUS.INACTIVE; + + useEffect(() => { + if (!venueId || isLoading || isError || !myVenues) return; + + if (!venue || isBlockedStatus) { + if (handledFailRef.current === venueId) return; + handledFailRef.current = venueId; + + if (!venue) { + error("Venue not found or you don't have access."); + } else { + error("This venue is not available for dashboard access."); + } + navigate("/dashboard/select-venue", { replace: true }); + return; + } + + handledFailRef.current = null; + setActiveVenue(venueId, venue.name, venue.status); + }, [ + venueId, + venue, + isBlockedStatus, + myVenues, + isLoading, + isError, + navigate, + setActiveVenue, + error, + ]); + + useEffect(() => { + if (venue && venueSubRoute) { + setLastVenueSubRoute(venueSubRoute); + } + }, [venueSubRoute, venue, setLastVenueSubRoute]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+

+ Failed to load venues. Please try again. +

+
+ ); + } + + if (isAccessDenied) { + return ( +
+ +
+ ); + } + + return ( + <> + {isInactive && ( +
+ This venue is currently inactive. New bookings are blocked. +
+ )} + + + ); +} diff --git a/admin/src/components/moderation/BannedUsersTable.tsx b/admin/src/components/moderation/BannedUsersTable.tsx new file mode 100644 index 0000000000..9b23731238 --- /dev/null +++ b/admin/src/components/moderation/BannedUsersTable.tsx @@ -0,0 +1,128 @@ +import { RotateCcw } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { ModerationSummary } from "@/types"; +import type { UseMutationResult } from "@tanstack/react-query"; + +export function BannedUsersTable({ + bannedUsers, + unbanUserMutation, +}: { + bannedUsers: ModerationSummary["bannedUsers"]; + + unbanUserMutation: UseMutationResult< + unknown, + unknown, + { userId: string }, + unknown + >; +}) { + const columns = [ + { + accessorKey: "username", + header: "User", + cell: ({ + row, + }: { + row: { original: ModerationSummary["bannedUsers"][0] }; + }) => ( +
+

+ {row.original.username} +

+

+ {row.original.email} +

+
+ ), + }, + { + accessorKey: "banReason", + header: "Ban Reason", + cell: ({ + row, + }: { + row: { original: ModerationSummary["bannedUsers"][0] }; + }) => ( +

+ {row.original.banReason} +

+ ), + }, + { + accessorKey: "bannedAt", + header: "Banned On", + cell: ({ + row, + }: { + row: { original: ModerationSummary["bannedUsers"][0] }; + }) => ( +

+ {new Date(row.original.bannedAt).toLocaleDateString()} +

+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: () => ( + + Banned + + ), + }, + { + id: "actions", + header: "Actions", + cell: ({ + row, + }: { + row: { original: ModerationSummary["bannedUsers"][0] }; + }) => ( + + + + + + +

Unban

+
+
+
+ ), + }, + ]; + + return ( + {}} + emptyMessage="No banned users." + /> + ); +} diff --git a/admin/src/components/moderation/FlaggedReviewsTable.tsx b/admin/src/components/moderation/FlaggedReviewsTable.tsx new file mode 100644 index 0000000000..20ca9d9730 --- /dev/null +++ b/admin/src/components/moderation/FlaggedReviewsTable.tsx @@ -0,0 +1,185 @@ +import { Trash2, RotateCcw, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +import { StarRating } from "@/components/common/StarRating"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CLIENT_APP_URL } from "@/constants"; +import type { ModerationSummary } from "@/types"; +import type { ReviewActionDialogState } from "@/types/ui"; +import type { UseMutationResult } from "@tanstack/react-query"; + +export function FlaggedReviewsTable({ + flaggedReviews, + setReviewDialog, + moderateReviewMutation, +}: { + flaggedReviews: ModerationSummary["flaggedReviews"]; + + setReviewDialog: (opts: ReviewActionDialogState) => void; + + moderateReviewMutation: UseMutationResult< + unknown, + unknown, + { + reviewId: string; + action: "remove" | "reject_hide" | "restore" | "approve_hide"; + reason?: string; + }, + unknown + >; +}) { + const columns = [ + { + accessorKey: "venueName", + header: "Venue & User", + cell: ({ + row, + }: { + row: { original: ModerationSummary["flaggedReviews"][0] }; + }) => ( +
+

+ {row.original.venueName} +

+

+ by {row.original.userName} +

+
+ ), + }, + { + accessorKey: "comment", + header: "Review & Rating", + cell: ({ + row, + }: { + row: { original: ModerationSummary["flaggedReviews"][0] }; + }) => ( + + + +
+ +

+ {row.original.comment} +

+
+
+ + +

{row.original.comment}

+
+
+
+ ), + }, + { + accessorKey: "moderationReason", + header: "Flag Reason", + cell: ({ + row, + }: { + row: { original: ModerationSummary["flaggedReviews"][0] }; + }) => ( +

+ {row.original.moderationReason || "No reason provided"} +

+ ), + }, + { + id: "actions", + header: "Actions", + cell: ({ + row, + }: { + row: { original: ModerationSummary["flaggedReviews"][0] }; + }) => ( +
+ + + + + + +

Remove

+
+
+
+ + + + + + +

Restore

+
+
+
+ + + + + + +

View on site

+
+
+
+
+ ), + }, + ]; + + return ( + {}} + emptyMessage="No flagged reviews found." + /> + ); +} diff --git a/admin/src/components/moderation/HideRequestsTable.tsx b/admin/src/components/moderation/HideRequestsTable.tsx new file mode 100644 index 0000000000..3098ffb59e --- /dev/null +++ b/admin/src/components/moderation/HideRequestsTable.tsx @@ -0,0 +1,188 @@ +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +import { StarRating } from "@/components/common/StarRating"; +import { Check, X, ExternalLink } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CLIENT_APP_URL } from "@/constants"; +import type { ModerationSummary } from "@/types"; +import type { ReviewActionDialogState } from "@/types/ui"; +import type { UseMutationResult } from "@tanstack/react-query"; + +export function HideRequestsTable({ + hideRequests, + setReviewDialog, + moderateReviewMutation, +}: { + hideRequests: ModerationSummary["hideRequests"]; + + setReviewDialog: (opts: ReviewActionDialogState) => void; + + moderateReviewMutation: UseMutationResult< + unknown, + unknown, + { + reviewId: string; + action: "remove" | "reject_hide" | "restore" | "approve_hide"; + reason?: string; + }, + unknown + >; +}) { + const columns = [ + { + accessorKey: "venueName", + header: "Venue & Parties", + cell: ({ + row, + }: { + row: { original: ModerationSummary["hideRequests"][0] }; + }) => ( +
+

+ {row.original.venueName} +

+

+ Owner: {row.original.ownerUsername} +

+

+ Reviewer: {row.original.userName} +

+
+ ), + }, + { + accessorKey: "comment", + header: "Review & Rating", + cell: ({ + row, + }: { + row: { original: ModerationSummary["hideRequests"][0] }; + }) => ( + + + +
+ +

+ {row.original.comment} +

+
+
+ + +

{row.original.comment}

+
+
+
+ ), + }, + { + accessorKey: "hideRequestReason", + header: "Hide Reason", + cell: ({ + row, + }: { + row: { original: ModerationSummary["hideRequests"][0] }; + }) => ( +

+ {row.original.hideRequestReason} +

+ ), + }, + { + id: "actions", + header: "Actions", + cell: ({ + row, + }: { + row: { original: ModerationSummary["hideRequests"][0] }; + }) => ( +
+ + + + + + +

Approve

+
+
+
+ + + + + + +

Reject

+
+
+
+ + + + + + +

View on site

+
+
+
+
+ ), + }, + ]; + + return ( + {}} + emptyMessage="No pending hide requests." + /> + ); +} diff --git a/admin/src/components/moderation/ReviewActionDialog.tsx b/admin/src/components/moderation/ReviewActionDialog.tsx new file mode 100644 index 0000000000..14ac362aa5 --- /dev/null +++ b/admin/src/components/moderation/ReviewActionDialog.tsx @@ -0,0 +1,133 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import type { ReviewActionDialogState } from "@/types/ui"; +import { MIN_REASON_LENGTH } from "@/constants/validation"; +import type { UseMutationResult } from "@tanstack/react-query"; + +export function ReviewActionDialog({ + reviewDialog, + setReviewDialog, + reviewReason, + setReviewReason, + moderateReviewMutation, +}: { + reviewDialog: ReviewActionDialogState; + + setReviewDialog: (state: ReviewActionDialogState) => void; + reviewReason: string; + setReviewReason: (val: string) => void; + + moderateReviewMutation: UseMutationResult< + unknown, + unknown, + { + reviewId: string; + action: "remove" | "reject_hide" | "restore" | "approve_hide"; + reason?: string; + }, + unknown + >; +}) { + return ( + setReviewDialog({ ...reviewDialog, open })} + > + + + + {reviewDialog.action === "remove" + ? "Remove Review" + : reviewDialog.action === "approve_hide" + ? "Approve Hide Request" + : "Restore Review"} + + + {reviewDialog.action === "remove" + ? "Are you sure you want to remove this review? This action cannot be undone." + : reviewDialog.action === "approve_hide" + ? "Approving this hide request will remove the review. Please provide a reason (minimum 10 characters)." + : "Restore this review to be visible again?"} + + + + {(reviewDialog.action === "remove" || + reviewDialog.action === "approve_hide") && ( +
+
+ + setReviewReason(e.target.value)} + className="rounded-lg border border-[var(--bg-grey)] bg-[var(--bg-primary)]" + /> + {reviewReason.trim().length > 0 && + reviewReason.trim().length < MIN_REASON_LENGTH && ( +

+ Reason must be at least 10 characters. +

+ )} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/admin/src/components/moderation/SuspendedVenuesTable.tsx b/admin/src/components/moderation/SuspendedVenuesTable.tsx new file mode 100644 index 0000000000..af13df713e --- /dev/null +++ b/admin/src/components/moderation/SuspendedVenuesTable.tsx @@ -0,0 +1,123 @@ +import { RotateCcw } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { ModerationSummary } from "@/types"; +import type { UseMutationResult } from "@tanstack/react-query"; + +export function SuspendedVenuesTable({ + suspendedVenues, + unsuspendVenueMutation, +}: { + suspendedVenues: ModerationSummary["suspendedVenues"]; + + unsuspendVenueMutation: UseMutationResult< + unknown, + unknown, + { venueId: string }, + unknown + >; +}) { + const columns = [ + { + accessorKey: "name", + header: "Venue", + cell: ({ + row, + }: { + row: { original: ModerationSummary["suspendedVenues"][0] }; + }) => ( +

+ {row.original.name} +

+ ), + }, + { + accessorKey: "suspensionReason", + header: "Reason", + cell: ({ + row, + }: { + row: { original: ModerationSummary["suspendedVenues"][0] }; + }) => ( +

+ {row.original.suspensionReason} +

+ ), + }, + { + accessorKey: "suspendedAt", + header: "Suspended On", + cell: ({ + row, + }: { + row: { original: ModerationSummary["suspendedVenues"][0] }; + }) => ( +

+ {new Date(row.original.suspendedAt).toLocaleDateString()} +

+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: () => ( + + Suspended + + ), + }, + { + id: "actions", + header: "Actions", + cell: ({ + row, + }: { + row: { original: ModerationSummary["suspendedVenues"][0] }; + }) => ( + + + + + + +

Unsuspend

+
+
+
+ ), + }, + ]; + + return ( + {}} + emptyMessage="No suspended venues." + /> + ); +} diff --git a/admin/src/components/reports/MetricCardsGrid.tsx b/admin/src/components/reports/MetricCardsGrid.tsx new file mode 100644 index 0000000000..d8a1bbb9ed --- /dev/null +++ b/admin/src/components/reports/MetricCardsGrid.tsx @@ -0,0 +1,52 @@ +import { TrendingUp, DollarSign, CalendarCheck } from "lucide-react"; +import { MetricCard } from "@/components/common/MetricCard"; + +export function MetricCardsGrid({ + isLoading, + totalRevenue, + totalBookings, + avgMonthlyRevenue, + monthsLength, +}: { + isLoading: boolean; + totalRevenue: number; + totalBookings: number; + avgMonthlyRevenue: number; + monthsLength: number; +}) { + if (isLoading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + return ( +
+ + + +
+ ); +} diff --git a/admin/src/components/reports/MonthlyBreakdownTable.tsx b/admin/src/components/reports/MonthlyBreakdownTable.tsx new file mode 100644 index 0000000000..85bc0d7120 --- /dev/null +++ b/admin/src/components/reports/MonthlyBreakdownTable.tsx @@ -0,0 +1,50 @@ +import type { MonthData } from "@/types/reports.types"; + +export function MonthlyBreakdownTable({ + isLoading, + months, + monthNames, +}: { + isLoading: boolean; + months: MonthData[]; + monthNames: Record; +}) { + if (isLoading || months.length === 0) return null; + + return ( +
+
+

+ Monthly Breakdown +

+
+ + + + + + + + + + {[...months].reverse().map((row) => ( + + + + + + ))} + +
PeriodBookingsRevenue
+ {monthNames[row.month] ?? row.month} '{row.year.slice(2)} + + {row.count} + + ₹{row.revenue.toLocaleString("en-IN")} +
+
+ ); +} diff --git a/admin/src/components/reports/RevenueChart.tsx b/admin/src/components/reports/RevenueChart.tsx new file mode 100644 index 0000000000..181c3d991e --- /dev/null +++ b/admin/src/components/reports/RevenueChart.tsx @@ -0,0 +1,85 @@ +import { BarChart3 } from "lucide-react"; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { CustomTooltip } from "@/components/common/CustomTooltip"; + +export function RevenueChart({ + isLoading, + chartData, +}: { + isLoading: boolean; + chartData: { label: string; revenue: number; bookings: number }[]; +}) { + return ( +
+
+
+

+ Monthly Revenue +

+

+ Revenue trend from completed bookings +

+
+
+ + {isLoading ? ( +
+ ) : chartData.length === 0 ? ( +
+ +

No completed bookings yet

+
+ ) : ( + + + + + + + + + + + `₹${(v / 1000).toFixed(0)}k`} + tick={{ fontSize: 12, fill: "#9ca3af" }} + tickLine={false} + axisLine={false} + width={52} + /> + } /> + + + + )} +
+ ); +} diff --git a/admin/src/components/reviews/ReplyDialog.tsx b/admin/src/components/reviews/ReplyDialog.tsx new file mode 100644 index 0000000000..11ab945877 --- /dev/null +++ b/admin/src/components/reviews/ReplyDialog.tsx @@ -0,0 +1,80 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import type { ReplyDialogState } from "@/types/ui"; +import { MAX_TEXT_LENGTH } from "@/constants/validation"; + +export function ReplyDialog({ + replyDialog, + setReplyDialog, + replyText, + setReplyText, + handleReply, + isPending, +}: { + replyDialog: ReplyDialogState; + setReplyDialog: (opts: ReplyDialogState) => void; + replyText: string; + setReplyText: (text: string) => void; + handleReply: () => void; + isPending: boolean; +}) { + return ( + setReplyDialog({ open })} + > + + + Reply to Review + + Share your response to this customer's review. + + + +
+
+ + +
+ {reason.length}/200 +
+
+
+ +
+ + +
+
+
+ ); +}; + +export default CancelBookingModal; diff --git a/client/src/components/common/Footer.tsx b/client/src/components/common/Footer.tsx new file mode 100644 index 0000000000..3501ddf36f --- /dev/null +++ b/client/src/components/common/Footer.tsx @@ -0,0 +1,210 @@ +import { Link, useLocation } from 'react-router'; +import { IoLogoFacebook, IoLogoTwitter, IoLogoInstagram, IoLogoLinkedin } from 'react-icons/io5'; +import bmvLogo from '@/assets/bmv-logo.png'; + +const Footer = () => { + const location = useLocation(); + const isHomePage = location.pathname === '/'; + + if (isHomePage) { + return ( +
+
+
+ {/* Column 1: Brand */} +
+ + BookMyVenue + +

+ Your premier destination for discovering and booking the perfect venues for any + occasion. From weddings to corporate retreats, we make event planning seamless. +

+ +
+ + {/* Column 2: Company */} +
+

+ Company +

+
    +
  • + + About Us + +
  • +
  • + + Legal Information + +
  • +
  • + + Contact Us + +
  • +
  • + + Blogs + +
  • +
+
+ + {/* Column 3: Help Center */} +
+

+ Help Center +

+
    +
  • + + Find a Property + +
  • +
  • + + How To Host? + +
  • +
  • + + Why Us? + +
  • +
  • + + FAQs + +
  • +
+
+ + {/* Column 4: Contact Info */} +
+

+ Contact Info +

+
    +
  • + Phone: + 1-800-BOOK-VENUE +
  • +
  • + Email: + support@bookmyvenue.com +
  • +
  • + + Location: + + Kochi, Kerala, India +
  • +
+
+
+ +
+

+ © {new Date().getFullYear()} BookMyVenue. All rights reserved. +

+
+
+
+ ); + } + + // Regular footer for other pages + return ( +
+
+
+ {/* Brand */} +
+ BookMyVenue + +

+ Find and book the perfect venue for your events. +

+
+ +
+

Quick Links

+ +
+ + Explore Venues + + + + My Bookings + + + + List Your Venue + +
+
+ +
+

Legal

+ +
+ + Privacy Policy + + + + Terms & Conditions + +
+
+
+ +
+ © {new Date().getFullYear()} BookMyVenue. All rights reserved. +
+
+
+ ); +}; + +export default Footer; diff --git a/client/src/components/common/GuestGuard.tsx b/client/src/components/common/GuestGuard.tsx new file mode 100644 index 0000000000..84b2d06d03 --- /dev/null +++ b/client/src/components/common/GuestGuard.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Navigate, Outlet, useLocation } from 'react-router'; +import { getSafeRedirectUrl } from '@/utils/redirect'; +import { useAuth } from '@/hooks/useAuth'; + +const GuestGuard: React.FC = () => { + const { isAuthenticated, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return ( +
+
+
+ ); + } + + if (isAuthenticated) { + const searchParams = new URLSearchParams(location.search); + const redirectParam = searchParams.get('redirect'); + const safeRedirect = getSafeRedirectUrl(redirectParam); + return ; + } + + return ; +}; + +export default GuestGuard; diff --git a/client/src/components/common/ImageLightbox.tsx b/client/src/components/common/ImageLightbox.tsx new file mode 100644 index 0000000000..1b5179e9de --- /dev/null +++ b/client/src/components/common/ImageLightbox.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react'; +import { FiX, FiChevronLeft, FiChevronRight } from 'react-icons/fi'; + +interface ImageLightboxProps { + images: string[]; + initialIndex?: number; + onClose: () => void; +} + +const ImageLightbox = ({ images, initialIndex = 0, onClose }: ImageLightboxProps) => { + const [index, setIndex] = useState(initialIndex); + const hasMultiple = images.length > 1; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + if (hasMultiple && e.key === 'ArrowLeft') { + setIndex((prev) => (prev - 1 + images.length) % images.length); + } + if (hasMultiple && e.key === 'ArrowRight') { + setIndex((prev) => (prev + 1) % images.length); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose, hasMultiple, images.length]); + + if (images.length === 0) return null; + + return ( +
+ + + {hasMultiple && ( + <> + + +
+ {index + 1} / {images.length} +
+ + )} + + e.stopPropagation()} + className="max-h-[90vh] max-w-[90vw] object-contain rounded-lg" + /> +
+ ); +}; + +export default ImageLightbox; diff --git a/client/src/components/common/ImageUpload.tsx b/client/src/components/common/ImageUpload.tsx new file mode 100644 index 0000000000..bd96688d61 --- /dev/null +++ b/client/src/components/common/ImageUpload.tsx @@ -0,0 +1,170 @@ +import { useState, useEffect, useRef } from 'react'; +import { X, Plus } from 'lucide-react'; + +interface ImageUploadProps { + fieldName: 'coverImage' | 'galleryImages'; + existingUrls: string[]; + onChange: (files: File[], existingUrls: string[]) => void; + maxFiles?: number; +} + +export function ImageUpload({ + fieldName, + existingUrls, + onChange, + maxFiles = 10, +}: ImageUploadProps) { + const [previewUrls, setPreviewUrls] = useState(() => existingUrls); + const [files, setFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const prevExistingUrlsRef = useRef(existingUrls); + + useEffect(() => { + if (prevExistingUrlsRef.current !== existingUrls) { + prevExistingUrlsRef.current = existingUrls; + setPreviewUrls(existingUrls); + } + }, [existingUrls]); + + useEffect(() => { + return () => { + previewUrls.forEach((url) => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + }; + }, [previewUrls]); + + const handleFileChange = (newFiles: FileList | File[]) => { + const fileArray = Array.from(newFiles); + const remainingSlots = maxFiles - previewUrls.length; + const filesToAdd = fileArray.slice(0, remainingSlots); + + const newPreviewUrls = filesToAdd.map((file) => URL.createObjectURL(file)); + setPreviewUrls((prev) => [...prev, ...newPreviewUrls]); + setFiles((prev) => [...prev, ...filesToAdd]); + onChange(filesToAdd, previewUrls); + }; + + const removeImage = (index: number) => { + const isExisting = index < existingUrls.length; + + if (isExisting) { + const newExisting = existingUrls.filter((_, i) => i !== index); + setPreviewUrls((prev) => { + const urlToRevoke = prev[index]; + if (urlToRevoke.startsWith('blob:')) { + URL.revokeObjectURL(urlToRevoke); + } + return prev.filter((_, i) => i !== index); + }); + onChange(files, newExisting); + } else { + const fileIndex = index - existingUrls.length; + setPreviewUrls((prev) => { + const urlToRevoke = prev[index]; + if (urlToRevoke.startsWith('blob:')) { + URL.revokeObjectURL(urlToRevoke); + } + return prev.filter((_, i) => i !== index); + }); + setFiles((prev) => prev.filter((_, i) => i !== fileIndex)); + onChange( + files.filter((_, i) => i !== fileIndex), + existingUrls + ); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files.length > 0) { + handleFileChange(e.dataTransfer.files); + } + }; + + const isCoverImage = fieldName === 'coverImage'; + const maxAllowed = isCoverImage ? 1 : maxFiles; + const canAddMore = previewUrls.length < maxAllowed; + + return ( +
+ + +
+
+ {previewUrls.map((url, index) => ( +
+ {`Preview + +
+ ))} + + {canAddMore && ( + + )} +
+ + {previewUrls.length > 0 && ( +

+ {isCoverImage + ? 'Click X to replace cover image' + : 'Drag to reorder (first image is cover)'} +

+ )} +
+
+ ); +} diff --git a/client/src/components/common/ListSidebar.tsx b/client/src/components/common/ListSidebar.tsx new file mode 100644 index 0000000000..0886d374b5 --- /dev/null +++ b/client/src/components/common/ListSidebar.tsx @@ -0,0 +1,76 @@ +import { Link, useLocation } from 'react-router'; +import { MdOutlineMeetingRoom } from 'react-icons/md'; +import { IoAddCircleOutline } from 'react-icons/io5'; +import bmvLogo from '@/assets/bmv-logo.png'; + +const ListSidebar = () => { + const location = useLocation(); + + const isActive = (path: string) => location.pathname.includes(path); + + return ( + <> + {/* Desktop Sidebar */} + + + {/* Mobile Bottom Nav */} + + + ); +}; + +export default ListSidebar; diff --git a/client/src/components/common/Navbar.tsx b/client/src/components/common/Navbar.tsx new file mode 100644 index 0000000000..e2972c1c05 --- /dev/null +++ b/client/src/components/common/Navbar.tsx @@ -0,0 +1,264 @@ +import { useState, useEffect, useRef } from 'react'; + +import { Link, useLocation } from 'react-router'; +import bmvLogo from '@/assets/bmv-logo.png'; +import { AnimatePresence, motion } from 'framer-motion'; +import ProfileDropdown from './ProfileDropdown'; +import ThemeToggle from './ThemeToggle'; +import { useAuth } from '@/hooks/useAuth'; +import { FiUser } from 'react-icons/fi'; +import { hasPlayedProfileGreeting, markProfileGreetingPlayed } from '@/utils/profileGreeting'; + +// Greet returning users on load so the profile icon reads as clickable: +// icon-only -> pill expands with "Hi there" -> swaps to the user's name -> converges back to the icon. +const GREETING_DELAY_MS = 3500; +const HI_HOLD_MS = 3000; +const NAME_HOLD_MS = 3200; +const PILL_FADE_MS = 750; +const PILL_WIDTH = 70; + +type GreetingStage = 'idle' | 'hi' | 'name' | 'done'; + +const Navbar = () => { + const [openProfile, setOpenProfile] = useState(false); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const { isAuthenticated, loading, user } = useAuth(); + const location = useLocation(); + const profileButtonRef = useRef(null); + + const isActive = (path: string) => location.pathname === path; + + const [scrolled, setScrolled] = useState(false); + + useEffect(() => { + const onScroll = () => setScrolled(window.scrollY > 20); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); + + const [greetingStage, setGreetingStage] = useState(() => + hasPlayedProfileGreeting() ? 'done' : 'idle' + ); + const greetingStarted = useRef(false); + + useEffect(() => { + if (!isAuthenticated || greetingStarted.current || hasPlayedProfileGreeting()) return; + greetingStarted.current = true; + + const timer = setTimeout(() => { + markProfileGreetingPlayed(); + setGreetingStage('hi'); + }, GREETING_DELAY_MS); + return () => clearTimeout(timer); + }, [isAuthenticated]); + + useEffect(() => { + if (greetingStage === 'hi') { + const timer = setTimeout(() => setGreetingStage('name'), HI_HOLD_MS); + return () => clearTimeout(timer); + } + if (greetingStage === 'name') { + const timer = setTimeout(() => setGreetingStage('done'), NAME_HOLD_MS); + return () => clearTimeout(timer); + } + }, [greetingStage]); + + const showGreetingPill = greetingStage === 'hi' || greetingStage === 'name'; + const firstName = user?.username?.split(' ')[0] || 'there'; + + // On mobile: toggle the nav tray. On desktop: toggle the profile dropdown. + const handleProfileClick = () => { + if (window.innerWidth < 768) { + setMobileNavOpen((prev) => !prev); + } else { + setOpenProfile((prev) => !prev); + } + }; + + return ( +
+
+ {/* left - logo */} + + BookMyVenue + + + {/* center nav */} + + + {/* profile / login */} +
+ + {!loading && + (isAuthenticated ? ( +
+ + {/* ProfileDropdown only appears on desktop click */} + + {openProfile && ( + setOpenProfile(false)} + triggerRef={profileButtonRef} + /> + )} + +
+ ) : ( + { + if (window.innerWidth < 768) { + setMobileNavOpen((prev) => !prev); + } + }} + className="px-5 py-2 rounded-xl bg-[var(--bg-secondary)] border-2 border-transparent hover:bg-[var(--bg-primary)] hover:border-2 hover:text-[var(--text-primary)] hover:border-[var(--bg-secondary)] transition-all font-medium text-white text-sm" + > + Login + + ))} +
+
+ + {/* Mobile Nav Tray (Horizontal Slide Down) */} +
+
+
+ setMobileNavOpen(false)} + className={`text-sm font-medium transition-colors ${ + isActive('/explore') + ? 'text-[var(--bg-green)] border-b-2 border-[var(--bg-green)]' + : 'text-[var(--text-secondary)] hover:text-[var(--bg-green)]' + }`} + > + Explore + + setMobileNavOpen(false)} + className={`text-sm font-medium transition-colors ${ + isActive('/list-venue') + ? 'text-[var(--bg-green)] border-b-2 border-[var(--bg-green)]' + : 'text-[var(--text-secondary)] hover:text-[var(--bg-green)]' + }`} + > + List Venue + + setMobileNavOpen(false)} + className={`text-sm font-medium transition-colors ${ + isActive('/my-bookings') + ? 'text-[var(--bg-green)] border-b-2 border-[var(--bg-green)]' + : 'text-[var(--text-secondary)] hover:text-[var(--bg-green)]' + }`} + > + My Bookings + + {isAuthenticated && ( + setMobileNavOpen(false)} + className={`text-sm font-medium transition-colors ${ + isActive('/profile') + ? 'text-[var(--bg-green)] border-b-2 border-[var(--bg-green)]' + : 'text-[var(--text-secondary)] hover:text-[var(--bg-green)]' + }`} + > + Profile + + )} +
+
+
+
+ ); +}; + +export default Navbar; diff --git a/client/src/components/common/ProfileDropdown.tsx b/client/src/components/common/ProfileDropdown.tsx new file mode 100644 index 0000000000..072ec4cc40 --- /dev/null +++ b/client/src/components/common/ProfileDropdown.tsx @@ -0,0 +1,166 @@ +import { useRef, useEffect, useState, type RefObject } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { motion } from 'framer-motion'; +import { FiLogOut, FiUser, FiX, FiHeart } from 'react-icons/fi'; +import { LuCalendarDays } from 'react-icons/lu'; +import { MdOutlineMeetingRoom } from 'react-icons/md'; +import { useAuth } from '@/hooks/useAuth'; + +type ProfileDropdownProps = { + onClose: () => void; + triggerRef?: RefObject; +}; + +// Mobile slides in as a full-height tray; desktop drops down from the avatar. +const desktopVariants = { + initial: { opacity: 0, y: -8, scale: 0.97 }, + animate: { opacity: 1, y: 0, scale: 1 }, + exit: { opacity: 0, y: -8, scale: 0.97 }, +}; + +const mobileVariants = { + initial: { opacity: 0, x: '100%' }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: '100%' }, +}; + +const useIsDesktop = () => { + const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 768px)').matches); + + useEffect(() => { + const mql = window.matchMedia('(min-width: 768px)'); + const handleChange = (e: MediaQueryListEvent) => setIsDesktop(e.matches); + mql.addEventListener('change', handleChange); + return () => mql.removeEventListener('change', handleChange); + }, []); + + return isDesktop; +}; + +const ProfileDropdown = ({ onClose, triggerRef }: ProfileDropdownProps) => { + const { user, logout } = useAuth(); + const dropdownRef = useRef(null); + const navigate = useNavigate(); + const isDesktop = useIsDesktop(); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (dropdownRef.current && dropdownRef.current.contains(target)) return; + if (triggerRef?.current && triggerRef.current.contains(target)) return; + onClose(); + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose, triggerRef]); + + const handleLogout = async () => { + await logout(); + onClose(); + navigate('/login'); + }; + + return ( + <> + {/* Mobile Backdrop */} + + + {/* Header */} +
+ {/* Avatar */} +
+ +
+ + {/* User info */} +
+

+ {user?.username || 'User'} +

+

+ {user?.email || 'user@example.com'} +

+
+ + {/* Close button — mobile only */} + +
+ + {/* Menu items */} +
+ + + Profile + + + + + My Bookings + + + + + My Wishlist + + + + + My Venues + + +
+ +
+
+
+ + ); +}; + +export default ProfileDropdown; diff --git a/client/src/components/common/ReviewModal.tsx b/client/src/components/common/ReviewModal.tsx new file mode 100644 index 0000000000..a5c626ad0b --- /dev/null +++ b/client/src/components/common/ReviewModal.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { Star } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +type ReviewModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + venueName: string; + onSubmit?: (rating: number, comment: string) => Promise; +}; + +const ReviewModal = ({ open, onOpenChange, venueName, onSubmit }: ReviewModalProps) => { + const [rating, setRating] = useState(0); + const [review, setReview] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + if (rating === 0) { + alert('Please select a rating'); + return; + } + + if (!onSubmit) { + alert('Submit handler not configured'); + return; + } + + setIsSubmitting(true); + try { + await onSubmit(rating, review); + setRating(0); + setReview(''); + onOpenChange(false); + } catch (error) { + console.error('Failed to submit review:', error); + alert('Failed to submit review. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + Add Review + + +

+ Share your experience for {venueName} +

+
+ + {/* Stars */} +
+

Your Rating

+ +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+
+ + {/* Review */} +
+

Review

+ + +
+
+ +
+ +
+ { + releaseLock({}); + navigate(-1); + }} + /> +
+
+
+ + ); +}; + +export default BookingCheckout; diff --git a/client/src/pages/Explore/components/VenueMap.tsx b/client/src/pages/Explore/components/VenueMap.tsx new file mode 100644 index 0000000000..43b1a836f5 --- /dev/null +++ b/client/src/pages/Explore/components/VenueMap.tsx @@ -0,0 +1,219 @@ +import { useEffect, useRef } from 'react'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { useVenuePins, type VenuePin } from '@/hooks/useVenuePins'; +import { + DEFAULT_MAP_CENTER, + DEFAULT_MAP_ZOOM, + MAP_MAX_ZOOM, + MAP_DEBOUNCE_MS, +} from '@/constants/map'; + +interface VenueMapProps { + onBBoxChange?: (bbox: { swLng: number; swLat: number; neLng: number; neLat: number }) => void; + selectedVenueId?: string; + onPinClick?: (venue: VenuePin) => void; + venues?: Array<{ + _id: string; + name: string; + location: { coordinates: [number, number] }; + coverImage: string; + avgRating?: number; + }>; +} + +export function VenueMap({ onBBoxChange, selectedVenueId, onPinClick, venues }: VenueMapProps) { + const mapContainer = useRef(null); + const mapRef = useRef(null); + const { pins, fetchPins, loading, error } = useVenuePins(); + const debounceTimerRef = useRef | null>(null); + const fetchPinsRef = useRef(fetchPins); + const onBBoxChangeRef = useRef(onBBoxChange); + + useEffect(() => { + fetchPinsRef.current = fetchPins; + onBBoxChangeRef.current = onBBoxChange; + }, [fetchPins, onBBoxChange]); + + useEffect(() => { + // Initialize map + const initMap = () => { + try { + if (!mapContainer.current) return; + + // Create map centered on Kerala, India + const map = L.map(mapContainer.current, { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + zoomControl: true, + scrollWheelZoom: true, + }); + + // Add OpenStreetMap tiles + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: MAP_MAX_ZOOM, + }).addTo(map); + + mapRef.current = map; + + // Fetch pins on map move + const handleMapMove = () => { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + + const timer = setTimeout(() => { + const bounds = map.getBounds(); + const bbox = { + swLng: bounds.getWest(), + swLat: bounds.getSouth(), + neLng: bounds.getEast(), + neLat: bounds.getNorth(), + }; + onBBoxChangeRef.current?.(bbox); + fetchPinsRef.current(bbox); + }, MAP_DEBOUNCE_MS); + + debounceTimerRef.current = timer; + }; + + map.on('moveend', handleMapMove); + + // Fetch initial pins + const initialBounds = map.getBounds(); + fetchPinsRef.current({ + swLng: initialBounds.getWest(), + swLat: initialBounds.getSouth(), + neLng: initialBounds.getEast(), + neLat: initialBounds.getNorth(), + }); + + return () => { + map.off('moveend', handleMapMove); + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + }; + } catch (err) { + console.error('Failed to initialize map:', err); + } + }; + + initMap(); + }, []); + + // Add/update markers when pins or venues change + useEffect(() => { + if (!mapRef.current) return; + + const pinsToShow = + venues && venues.length > 0 + ? venues.map((v) => ({ + _id: v._id, + name: v.name, + location: v.location, + coverImage: v.coverImage, + avgRating: v.avgRating || 0, + })) + : pins; + + if (!pinsToShow.length) return; + + const updateMarkers = () => { + // Clear existing markers + if (mapRef.current?.eachLayer) { + mapRef.current.eachLayer((layer: unknown) => { + if (L.Marker && layer instanceof L.Marker && mapRef.current?.removeLayer) { + mapRef.current.removeLayer(layer); + } + }); + } + + // Add new markers + pinsToShow.forEach((pin: VenuePin) => { + const [lng, lat] = pin.location.coordinates; + + const marker = L.marker([lat, lng], { + title: pin.name, + }).addTo(mapRef.current!); + + // Popup with venue info — build DOM programmatically to prevent XSS + const popupDiv = document.createElement('div'); + popupDiv.style.maxWidth = '200px'; + + const img = document.createElement('img'); + // Validate coverImage is HTTPS to prevent protocol-based XSS + if (pin.coverImage && pin.coverImage.startsWith('https://')) { + img.src = pin.coverImage; + } else { + img.src = '/placeholder-venue.png'; // Fallback to safe placeholder + } + img.alt = pin.name; + img.style.cssText = + 'width: 100%; height: 120px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;'; + popupDiv.appendChild(img); + + const title = document.createElement('h4'); + title.textContent = pin.name; + title.style.cssText = 'margin: 0 0 4px 0; font-size: 14px;'; + popupDiv.appendChild(title); + + const rating = document.createElement('div'); + rating.textContent = `⭐ ${pin.avgRating.toFixed(1)}`; + rating.style.cssText = 'font-size: 12px; color: var(--text-secondary);'; + popupDiv.appendChild(rating); + + marker.bindPopup(popupDiv); + + // Handle marker click + marker.on('click', () => { + onPinClick?.(pin); + }); + + // Highlight selected venue + if (selectedVenueId === pin._id) { + marker.setIcon( + L.icon({ + iconUrl: + 'data:image/svg+xml;utf8,', + iconSize: [32, 32], + iconAnchor: [16, 16], + }) + ); + } + }); + }; + + try { + updateMarkers(); + } catch (err) { + console.error(err); + } + }, [pins, venues, selectedVenueId, onPinClick]); + + return ( +
+
+ + {loading && ( +
+ Loading venues... +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ {pins.length} venue{pins.length !== 1 ? 's' : ''} found +
+
+ ); +} diff --git a/client/src/pages/Explore/index.tsx b/client/src/pages/Explore/index.tsx new file mode 100644 index 0000000000..d33ad94989 --- /dev/null +++ b/client/src/pages/Explore/index.tsx @@ -0,0 +1,191 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { useExploreVenues } from '@/hooks/useExploreVenues'; +import { useDebounce } from '@/hooks/useDebounce'; +import { useToggleWishlist, useWishlistSync } from '@/hooks/useWishlist'; +import type { VenueFilters } from '@/types/venue.types'; +import { FILTER_PRICE_STEPS } from '@/constants'; +import { Filter } from 'lucide-react'; +import { ExploreFilters } from '@/components/explore/ExploreFilters'; +import { VenueGrid } from '@/components/explore/VenueGrid'; + +const ExplorePage = () => { + const [filters, setFilters] = useState({}); + const [searchTerm, setSearchTerm] = useState(''); + const [isMobileFiltersOpen, setIsMobileFiltersOpen] = useState(false); + const [priceRangeIndex, setPriceRangeIndex] = useState<[number, number]>([ + 0, + FILTER_PRICE_STEPS.length - 1, + ]); + const [isScrolled, setIsScrolled] = useState(false); + const [togglingVenues, setTogglingVenues] = useState>(new Set()); + + const { toggleWishlist: toggleWishlistFn } = useToggleWishlist(); + useWishlistSync(); + + useEffect(() => { + const handleScroll = () => setIsScrolled(window.scrollY > 200); + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const debouncedSearchTerm = useDebounce(searchTerm, 400); + const debouncedPriceRangeIndex = useDebounce(priceRangeIndex, 400); + + const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage, isFetching } = + useExploreVenues({ + ...filters, + searchTerm: debouncedSearchTerm || undefined, + minPrice: FILTER_PRICE_STEPS[debouncedPriceRangeIndex[0]], + maxPrice: FILTER_PRICE_STEPS[debouncedPriceRangeIndex[1]], + }); + + const handleToggleWishlist = useCallback( + async (venueId: string, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setTogglingVenues((prev) => new Set([...prev, venueId])); + try { + await toggleWishlistFn(venueId); + } finally { + setTogglingVenues((prev) => { + const next = new Set(prev); + next.delete(venueId); + return next; + }); + } + }, + [toggleWishlistFn] + ); + + const observerRef = useRef(null); + const lastVenueElementRef = useCallback( + (node: HTMLDivElement | null) => { + if (isLoading || isFetchingNextPage) return; + if (observerRef.current) observerRef.current.disconnect(); + observerRef.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasNextPage) { + fetchNextPage(); + } + }); + if (node) observerRef.current.observe(node); + }, + [isLoading, isFetchingNextPage, hasNextPage, fetchNextPage] + ); + + const handleClearFilters = () => { + setFilters({}); + setSearchTerm(''); + setPriceRangeIndex([0, FILTER_PRICE_STEPS.length - 1]); + }; + + const venues = data?.pages.flatMap((page) => page.venues) || []; + const totalVenues = data?.pages[0]?.pagination?.total || 0; + + return ( +
+
+ + +
+
+
+

+ Find Your Perfect Venue +

+
+

+ Discover halls, resorts, auditoriums, turfs and more for every occasion. +

+
+ +
+ + setSearchTerm(e.target.value)} + /> +
+
+ +
+

+ {totalVenues} Venues Found +

+ +
+ + + + {isFetchingNextPage && ( +
+
+
+ )} +
+
+
+
+ ); +}; + +export default ExplorePage; diff --git a/client/src/pages/Explore/venueDetails/components/BookingSidebar.tsx b/client/src/pages/Explore/venueDetails/components/BookingSidebar.tsx new file mode 100644 index 0000000000..4e86a19179 --- /dev/null +++ b/client/src/pages/Explore/venueDetails/components/BookingSidebar.tsx @@ -0,0 +1,279 @@ +import { FiCalendar } from 'react-icons/fi'; +import { Calendar } from '@/components/ui/calendar'; +import { toLocalDateString } from '@/utils/timeUtils'; +import type { VenueDetail } from '@/types/venue.types'; +import type { Slot } from '@/types/booking.types'; +import { BOOKING_TYPES } from '@/constants/venueConstants'; + +interface BookingSidebarProps { + venue: VenueDetail; + selectedSlots: Slot[]; + setSelectedSlots: (slots: Slot[]) => void; + selectedDate: string; + setSelectedDate: (date: string) => void; + calendarVisible: boolean; + setCalendarVisible: (visible: boolean) => void; + bookableDatesData: { disabledDates: string[]; maxDate: string } | undefined; + isCalendarLoading: boolean; + availabilityResponse: + | { + slots: { + slotId: string; + name: string | null; + startTime: string; + endTime: string; + price: number; + isAvailable: boolean; + reason: string | null; + }[]; + } + | undefined; + isSlotsLoading: boolean; + totalPrice: number; + finalStartTime: string | null; + finalEndTime: string | null; + handleSlotSelect: (slot: Slot) => void; + handleProceedToBook: () => void; + isBlocking: boolean; + getStartingPrice: () => string; +} + +export function BookingSidebar({ + venue, + selectedSlots, + setSelectedSlots, + selectedDate, + setSelectedDate, + calendarVisible, + setCalendarVisible, + bookableDatesData, + isCalendarLoading, + availabilityResponse, + isSlotsLoading, + totalPrice, + finalStartTime, + finalEndTime, + handleSlotSelect, + handleProceedToBook, + isBlocking, +}: BookingSidebarProps) { + return ( +
+
+ + Booking + + +

Reserve your venue

+ +

+ Pick a date, choose your slots and continue to payment. +

+
+ +
+
+ + {!calendarVisible && selectedDate && ( + + )} +
+ +
+ {isCalendarLoading ? ( +
+
+
+ ) : ( +
+ { + if (date) { + const formatted = toLocalDateString(date); + setSelectedDate(formatted); + setSelectedSlots([]); + setCalendarVisible(false); + } + }} + disabled={(date) => { + if (!bookableDatesData) return true; + + const dateStr = toLocalDateString(date); + if (bookableDatesData.disabledDates.includes(dateStr)) { + return true; + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const maxDate = new Date(today); + maxDate.setDate(maxDate.getDate() + 90); + return date < tomorrow || date > maxDate; + }} + startMonth={new Date()} + endMonth={ + bookableDatesData?.maxDate + ? new Date(bookableDatesData.maxDate + 'T00:00:00') + : undefined + } + className="rounded-xl w-full flex justify-center" + /> +
+ )} +
+ + {!calendarVisible && selectedDate && ( +
+
+
+ +
+ + Date + +
+

+ {new Date(selectedDate + 'T00:00:00').toLocaleDateString('en-GB', { + weekday: 'short', + day: 'numeric', + month: 'short', + year: 'numeric', + })} +

+
+ )} +
+ +
+
+

+ Available Slots +

+ + {venue?.bookingType === BOOKING_TYPES.FIXED ? 'Fixed' : 'Flexible'} + +
+ + {isSlotsLoading ? ( +
+
+

+ Calculating availability... +

+
+ ) : ( +
+ {!availabilityResponse?.slots || availabilityResponse.slots.length === 0 ? ( +
+ {selectedDate + ? 'No slots available for the selected date.' + : 'Please pick a date to check slots availability.'} +
+ ) : ( +
+ {availabilityResponse.slots.map((slot) => { + const isSelected = selectedSlots.some((s) => s.slotId === slot.slotId); + return ( + + ); + })} +
+ )} +
+ )} +
+ +
+
+
+

Total

+ +

₹{totalPrice}

+
+ +
+

+ {selectedSlots.length} {selectedSlots.length === 1 ? 'Slot' : 'Slots'} +

+ + {finalStartTime && finalEndTime && ( +

+ {finalStartTime} → {finalEndTime} +

+ )} +
+
+
+ + +
+ ); +} diff --git a/client/src/pages/Explore/venueDetails/components/ReviewList.tsx b/client/src/pages/Explore/venueDetails/components/ReviewList.tsx new file mode 100644 index 0000000000..a86d50a0e9 --- /dev/null +++ b/client/src/pages/Explore/venueDetails/components/ReviewList.tsx @@ -0,0 +1,146 @@ +import type { Review } from '@/services/reviewService'; + +interface ReviewListProps { + isReviewsLoading: boolean; + reviews: Review[]; + pagination: { totalPages: number; page: number; total: number } | null; + fetchReviews: (venueId: string, page?: number) => void; + id: string | undefined; +} + +export function ReviewList({ + isReviewsLoading, + reviews, + pagination, + fetchReviews, + id, +}: ReviewListProps) { + if (isReviewsLoading) { + return ( +
+
+
+ ); + } + + if (reviews.length === 0) { + return ( +

+ No reviews yet. Be the first to drop some genuine review in! +

+ ); + } + + return ( +
+ {reviews.map((review) => ( +
+
+
+
+ {review.user.userName.charAt(0).toUpperCase()} +
+ {review.ownerReply &&
} +
+ +
+
+
+

+ {review.user.userName} +

+ {review.user.isVerified && ( + + Verified Customer + + )} +
+
+ {review.rating !== undefined && ( + + {[...Array(5)].map((_, i) => ( + + ★ + + ))} + + )} + + {new Date(review.createdAt).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + +
+
+ {review.comment && ( +

+ {review.comment} +

+ )} +
+ + {review.ownerReply && ( + <> +
+
+
+ +
+
+
+ O +
+
+
+ + Owner + + + {new Date(review.ownerReply.repliedAt).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + +
+

+ {review.ownerReply.text} +

+
+
+
+ + )} +
+
+ ))} + + {pagination && pagination.totalPages > 1 && ( +
+ {[...Array(pagination.totalPages)].map((_, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/client/src/pages/Explore/venueDetails/index.tsx b/client/src/pages/Explore/venueDetails/index.tsx new file mode 100644 index 0000000000..a4e431c4c5 --- /dev/null +++ b/client/src/pages/Explore/venueDetails/index.tsx @@ -0,0 +1,326 @@ +import { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router'; +import { FiMapPin, FiArrowRight } from 'react-icons/fi'; +import { TbBuildingOff } from 'react-icons/tb'; + +import { useApiQuery, useApiMutation } from '@/hooks/useApi'; +import { useVenueReviews } from '@/hooks/useVenueReviews'; +import { useAuth } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/useToast'; +import { API_ENDPOINTS } from '@/constants'; +import { BOOKING_TYPES, PRICING_TYPES } from '@/constants/venueConstants'; +import type { VenueDetail } from '@/types/venue.types'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; +import ReviewModal from '@/components/common/ReviewModal'; +import type { AxiosError } from 'axios'; + +import { VenueGallery } from '@/components/venue/VenueGallery'; +import { VenueAmenities } from '@/components/venue/VenueAmenities'; +import { VenueInfo } from '@/components/venue/VenueInfo'; + +// Extracted components & hooks +import { useVenueBooking } from '@/hooks/useVenueBooking'; +import { BookingSidebar } from './components/BookingSidebar'; +import { ReviewList } from './components/ReviewList'; + +const VenueDetails = () => { + const { id } = useParams<{ id: string }>(); + const { user } = useAuth(); + const { success, error: showError } = useToast(); + + const [showReviewModal, setShowReviewModal] = useState(false); + const [isReviewsExpanded, setIsReviewsExpanded] = useState(false); + + const { reviews, pagination, isLoading: isReviewsLoading, fetchReviews } = useVenueReviews(); + + useEffect(() => { + if (id) { + fetchReviews(id); + } + }, [id, fetchReviews]); + + const { + data: venue, + isLoading, + isError, + refetch, + } = useApiQuery( + ['venue', id || ''], + { + url: API_ENDPOINTS.VENUE_BY_ID(id as string), + method: 'GET', + }, + { + enabled: !!id, + } + ); + + const booking = useVenueBooking(id, venue); + + const submitReviewMutation = useApiMutation( + { + url: API_ENDPOINTS.VENUE_REVIEWS(id as string), + method: 'POST', + }, + { + onSuccess: () => { + success('Review submitted successfully'); + setShowReviewModal(false); + fetchReviews(id || ''); + }, + onError: (err: Error) => { + const axiosErr = err as AxiosError<{ message: string }>; + showError(axiosErr?.response?.data?.message || 'Failed to submit review'); + }, + } + ); + + if (isLoading) { + return ( +
+
+
+
+ + +
+ + + +
+
+ +
+
+
+ ); + } + + if (isError || !venue) { + return ( +
+
+
+ +
+

Venue Not Found

+

+ The venue you're looking for doesn't exist or may have been removed. +

+
+ + + ← Go to Explore + +
+
+
+ ); + } + + const images = venue.coverImage ? [venue.coverImage, ...(venue.galleryImages || [])] : []; + const amenities = venue.amenities || []; + + const getStartingPrice = () => { + if (venue.bookingType === 'flexibleBooking' && venue.pricing) { + return `₹${venue.pricing.basePrice}/${venue.pricing.pricingType === PRICING_TYPES.FIXED ? 'slot' : 'hr'}`; + } + if (venue.bookingType === BOOKING_TYPES.FIXED && venue.fixedPackages?.length > 0) { + const minPrice = Math.min(...venue.fixedPackages.map((p) => p.price)); + return `₹${minPrice}`; + } + return '--'; + }; + + return ( +
+
+
+
+
+
+ + Explore + + / + {venue.city?.toLowerCase() || 'Unknown City'} + / + + {venue.name?.toLowerCase() || 'Unknown Venue'} + +
+

+ {venue.name} +

+
+

+ + {venue.city}, {venue.district} +

+
+ + {venue.venueType} + +
+
+ + + + + +
+
setIsReviewsExpanded(!isReviewsExpanded)} + > +
+

+ Reviews & Ratings +

+
+ + {venue?.avgRating ? venue.avgRating.toFixed(1) : 'New'} ★ + + + ({pagination?.total ?? venue?.reviewCount ?? 0} reviews) + +
+
+
+ {isReviewsExpanded ? 'Hide' : 'View all'} +
+
+ + {isReviewsExpanded && ( +
+
+

All Reviews

+ {user && ( + + )} +
+ +
+ )} +
+ +
+
+
+

+ Reviews & Ratings +

+ {venue?.avgRating && ( +
+ + + {venue.avgRating.toFixed(1)} + + + ({pagination?.total ?? venue.reviewCount ?? 0} reviews) + +
+ )} +
+ {user && ( + + )} +
+ +
+
+
+ +
+
+ +
+
+
+ +
+ + + + + + + + + +
+ + { + await submitReviewMutation.mutateAsync({ rating, comment }); + }} + /> +
+ ); +}; + +export default VenueDetails; diff --git a/client/src/pages/NotFound/index.tsx b/client/src/pages/NotFound/index.tsx new file mode 100644 index 0000000000..aa64260a92 --- /dev/null +++ b/client/src/pages/NotFound/index.tsx @@ -0,0 +1,45 @@ +import { useNavigate } from 'react-router'; +import { Button } from '@/components/ui/button'; +import { FiHome, FiArrowLeft, FiAlertOctagon } from 'react-icons/fi'; + +const NotFound = () => { + const navigate = useNavigate(); + + return ( +
+
+ {/* Animated Background Ring */} +
+
+ +
+
+ +

404

+

Page Not Found

+ +

+ The venue you are looking for, or the page you requested, might have been moved, deleted, or + is temporarily unavailable. +

+ +
+ + +
+
+ ); +}; + +export default NotFound; diff --git a/client/src/pages/Profile/DevicesCard.tsx b/client/src/pages/Profile/DevicesCard.tsx new file mode 100644 index 0000000000..fcc3d9abb2 --- /dev/null +++ b/client/src/pages/Profile/DevicesCard.tsx @@ -0,0 +1,152 @@ +import { useMemo, useState } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { FiMonitor } from 'react-icons/fi'; +import { useSessions } from '@/hooks/useSessions'; +import { useToast } from '@/hooks/useToast'; +import { extractErrorMessage } from '@/utils/toast'; +import { parseUserAgent } from '@/utils/parseUserAgent'; +import * as authService from '@/services/authService'; +import Spinner from '@/components/common/Spinner'; + +const NEW_SESSION_REVOKE_LOCK_MS = 48 * 60 * 60 * 1000; + +const DevicesCard = () => { + const { data: sessions, isLoading, refetch } = useSessions(); + const toast = useToast(); + const [revokingId, setRevokingId] = useState(null); + const [revokingOthers, setRevokingOthers] = useState(false); + const [now] = useState(() => Date.now()); + + const currentSession = useMemo(() => sessions?.find((s) => s.isCurrent), [sessions]); + + const lockInfo = useMemo(() => { + if (!currentSession) return { locked: false, unlocksAt: null as Date | null }; + + const currentAgeMs = now - new Date(currentSession.createdAt).getTime(); + if (currentAgeMs >= NEW_SESSION_REVOKE_LOCK_MS) return { locked: false, unlocksAt: null }; + + const hasOlderSession = (sessions ?? []).some( + (s) => !s.isCurrent && new Date(s.createdAt) < new Date(currentSession.createdAt) + ); + + return { + locked: hasOlderSession, + unlocksAt: new Date( + new Date(currentSession.createdAt).getTime() + NEW_SESSION_REVOKE_LOCK_MS + ), + }; + }, [sessions, currentSession, now]); + + const anyActionInFlight = revokingId !== null || revokingOthers; + + const handleRevoke = async (sessionId: string) => { + if (anyActionInFlight) return; + setRevokingId(sessionId); + try { + await authService.revokeSession(sessionId); + toast.success('Device signed out'); + refetch(); + } catch (error) { + toast.error(extractErrorMessage(error) || 'Failed to sign out that device'); + } finally { + setRevokingId(null); + } + }; + + const handleRevokeOthers = async () => { + if (anyActionInFlight) return; + setRevokingOthers(true); + try { + const result = await authService.logoutOtherSessions(); + toast.success(`Signed out ${result.revokedCount} other device(s)`); + refetch(); + } catch (error) { + toast.error(extractErrorMessage(error) || 'Failed to sign out other devices'); + } finally { + setRevokingOthers(false); + } + }; + + const lockTooltip = lockInfo.unlocksAt + ? `Unlocks ${formatDistanceToNow(lockInfo.unlocksAt, { addSuffix: true })}` + : undefined; + + return ( +
+
+
+ + Devices +
+ {sessions && sessions.length > 1 && ( + + )} +
+ +
+ {isLoading && ( +

+ + Loading devices... +

+ )} + + {sessions?.map((session) => { + const isBlocked = + !session.isCurrent && + lockInfo.locked && + currentSession && + new Date(session.createdAt) < new Date(currentSession.createdAt); + + return ( +
+
+
+

+ {parseUserAgent(session.userAgent)} +

+ {session.isCurrent && ( + + This device + + )} +
+

+ {session.ipAddress} · Active{' '} + {formatDistanceToNow(new Date(session.lastLogin), { addSuffix: true })} +

+
+ + {!session.isCurrent && ( + + )} +
+ ); + })} +
+
+ ); +}; + +export default DevicesCard; diff --git a/client/src/pages/Profile/IdentityCard.tsx b/client/src/pages/Profile/IdentityCard.tsx new file mode 100644 index 0000000000..7831ad30da --- /dev/null +++ b/client/src/pages/Profile/IdentityCard.tsx @@ -0,0 +1,214 @@ +import { useRef, useState } from 'react'; +import { FiUser, FiCamera, FiCheck, FiX, FiEdit2, FiTrash2 } from 'react-icons/fi'; +import { useAuth } from '@/hooks/useAuth'; +import { useAvatarUpload } from '@/hooks/useAvatarUpload'; +import { updateProfile, deleteProfilePicture } from '@/services/userService'; +import { useToast } from '@/hooks/useToast'; +import { extractErrorMessage } from '@/utils/toast'; +import ImageLightbox from '@/components/common/ImageLightbox'; +import AvatarCropEditor from '@/components/common/AvatarCropEditor'; +import Spinner from '@/components/common/Spinner'; + +const IdentityCard = () => { + const { user, updateUser } = useAuth(); + const { uploadAvatar } = useAvatarUpload(); + const toast = useToast(); + const fileInputRef = useRef(null); + + const [editingName, setEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(user?.username ?? ''); + const [savingName, setSavingName] = useState(false); + + const [viewerOpen, setViewerOpen] = useState(false); + const [cropSrc, setCropSrc] = useState(null); + const [savingAvatar, setSavingAvatar] = useState(false); + const [deleting, setDeleting] = useState(false); + + const handleAvatarClick = () => fileInputRef.current?.click(); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + setCropSrc(URL.createObjectURL(file)); + }; + + const closeCropEditor = () => { + if (cropSrc) URL.revokeObjectURL(cropSrc); + setCropSrc(null); + }; + + const handleCropSave = async (croppedFile: File) => { + setSavingAvatar(true); + try { + const publicId = await uploadAvatar(croppedFile); + try { + const profile = await updateProfile({ profilePicturePublicId: publicId }); + updateUser({ profilePicture: profile.profilePicture }); + toast.success('Profile picture updated'); + closeCropEditor(); + } catch (error) { + toast.error(extractErrorMessage(error) || 'Failed to save profile picture'); + } + } catch { + // useAvatarUpload already has its own error toast for thee upload step + } finally { + setSavingAvatar(false); + } + }; + + const handleDelete = async () => { + if (deleting) return; + if (!window.confirm('Remove your profile picture?')) return; + + setDeleting(true); + try { + await deleteProfilePicture(); + updateUser({ profilePicture: undefined }); + toast.success('Profile picture removed'); + } catch (error) { + toast.error(extractErrorMessage(error) || 'Failed to remove profile picture'); + } finally { + setDeleting(false); + } + }; + + const startEditingName = () => { + setNameDraft(user?.username ?? ''); + setEditingName(true); + }; + + const cancelEditingName = () => setEditingName(false); + + const saveName = async () => { + if (savingName) return; + const trimmed = nameDraft.trim(); + if (!trimmed || trimmed === user?.username) { + setEditingName(false); + return; + } + + setSavingName(true); + try { + const profile = await updateProfile({ username: trimmed }); + updateUser({ username: profile.name }); + toast.success('Name updated'); + setEditingName(false); + } catch (error) { + toast.error(extractErrorMessage(error) || 'Failed to update name'); + } finally { + setSavingName(false); + } + }; + + return ( +
+
+ + + {user?.profilePicture && ( + + )} + +
+ +
+ {editingName ? ( +
+ setNameDraft(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveName()} + disabled={savingName} + className="min-w-0 flex-1 rounded-lg border border-[var(--bg-grey)] bg-[var(--bg-primary)] px-2 py-1 text-sm font-semibold text-[var(--text-primary)] outline-none focus:ring-2 focus:ring-[var(--bg-green)] disabled:opacity-50" + /> + + +
+ ) : ( +
+

{user?.username}

+ +
+ )} +

{user?.email}

+
+ + {viewerOpen && user?.profilePicture && ( + setViewerOpen(false)} /> + )} + + {cropSrc && ( + + )} +
+ ); +}; + +export default IdentityCard; diff --git a/client/src/pages/Profile/SecurityCard.tsx b/client/src/pages/Profile/SecurityCard.tsx new file mode 100644 index 0000000000..83f15d4bd1 --- /dev/null +++ b/client/src/pages/Profile/SecurityCard.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { FiLock } from 'react-icons/fi'; +import { useAuth } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/useToast'; +import { changePassword } from '@/services/authService'; +import { extractErrorMessage } from '@/utils/toast'; +import Spinner from '@/components/common/Spinner'; + +const SecurityCard = () => { + const { clearSession } = useAuth(); + const toast = useToast(); + const navigate = useNavigate(); + + const [expanded, setExpanded] = useState(false); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const reset = () => { + setExpanded(false); + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (submitting) return; + + if (newPassword.length < 8) { + toast.error('New password must be at least 8 characters'); + return; + } + if (newPassword !== confirmPassword) { + toast.error('New passwords do not match'); + return; + } + + setSubmitting(true); + try { + await changePassword(oldPassword, newPassword); + clearSession(); + navigate('/login', { replace: true }); + toast.success('Password changed. Please sign in again.'); + } catch (error) { + toast.error(extractErrorMessage(error) || 'Failed to change password'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+ + Password +
+ {!expanded && ( + + )} +
+ + {expanded && ( +
+ setOldPassword(e.target.value)} + required + disabled={submitting} + className="w-full rounded-lg border border-[var(--bg-grey)] bg-[var(--bg-primary)] px-3 py-2 text-sm text-[var(--text-primary)] outline-none focus:ring-2 focus:ring-[var(--bg-green)] disabled:opacity-50" + /> + setNewPassword(e.target.value)} + required + disabled={submitting} + className="w-full rounded-lg border border-[var(--bg-grey)] bg-[var(--bg-primary)] px-3 py-2 text-sm text-[var(--text-primary)] outline-none focus:ring-2 focus:ring-[var(--bg-green)] disabled:opacity-50" + /> + setConfirmPassword(e.target.value)} + required + disabled={submitting} + className="w-full rounded-lg border border-[var(--bg-grey)] bg-[var(--bg-primary)] px-3 py-2 text-sm text-[var(--text-primary)] outline-none focus:ring-2 focus:ring-[var(--bg-green)] disabled:opacity-50" + /> +

+ Changing your password signs you out of every device, including this one. +

+
+ + +
+
+ )} +
+ ); +}; + +export default SecurityCard; diff --git a/client/src/pages/Profile/index.tsx b/client/src/pages/Profile/index.tsx new file mode 100644 index 0000000000..09caa28149 --- /dev/null +++ b/client/src/pages/Profile/index.tsx @@ -0,0 +1,42 @@ +import { Link } from 'react-router'; +import { LuCalendarDays } from 'react-icons/lu'; +import { MdOutlineMeetingRoom } from 'react-icons/md'; +import { FiPlusCircle } from 'react-icons/fi'; +import IdentityCard from './IdentityCard'; +import SecurityCard from './SecurityCard'; +import DevicesCard from './DevicesCard'; + +const quickLinks = [ + { to: '/my-bookings', label: 'My Bookings', icon: LuCalendarDays }, + { to: '/list-venue/my-venues', label: 'My Venues', icon: MdOutlineMeetingRoom }, + { to: '/list-venue/add-venue', label: 'Add a Venue', icon: FiPlusCircle }, +]; + +const ProfilePage = () => { + return ( +
+
+

Profile

+ +
+ {quickLinks.map(({ to, label, icon: Icon }) => ( + + + {label} + + ))} +
+ + + + +
+
+ ); +}; + +export default ProfilePage; diff --git a/client/src/pages/Wishlist/index.tsx b/client/src/pages/Wishlist/index.tsx new file mode 100644 index 0000000000..ed2e5d8230 --- /dev/null +++ b/client/src/pages/Wishlist/index.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useAuth } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/useToast'; +import * as wishlistService from '@/services/wishlistService'; +import { Heart } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface WishlistVenue { + _id: string; + name: string; + city: string; + district: string; + coverImage: string; + maxCapacity?: number; + avgRating: number; + reviewCount: number; +} + +interface PaginationMeta { + total: number; + page: number; + totalPages: number; + hasMore: boolean; +} + +export default function WishlistPage() { + const [venues, setVenues] = useState([]); + const [loading, setLoading] = useState(true); + const [pagination, setPagination] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + + const navigate = useNavigate(); + const { user } = useAuth(); + const { error: showError } = useToast(); + + useEffect(() => { + if (!user) { + navigate('/auth/login'); + return; + } + + const fetchWishlist = async () => { + try { + setLoading(true); + const response = await wishlistService.getMyWishlist(currentPage, 20); + setVenues(response.data?.venues || []); + setPagination(response.data?.pagination || null); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to load wishlist'; + showError(errorMsg); + } finally { + setLoading(false); + } + }; + + fetchWishlist(); + }, [user, currentPage, navigate, showError]); + + const handleViewVenue = (venueId: string) => { + navigate(`/venue/${venueId}`); + }; + + const handleRemoveWishlist = async (venueId: string, e: React.MouseEvent) => { + e.stopPropagation(); + try { + await wishlistService.toggleWishlist(venueId); + setVenues((prev) => prev.filter((v) => v._id !== venueId)); + setPagination((prev) => (prev ? { ...prev, total: Math.max(0, prev.total - 1) } : null)); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to remove from wishlist'; + showError(errorMsg); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!venues.length) { + return ( +
+
+
+ +

+ Your wishlist is empty +

+

+ Start adding venues to your wishlist to save them for later +

+ +
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

My Wishlist

+

+ {pagination?.total} venue{pagination?.total !== 1 ? 's' : ''} saved +

+
+ + {/* Venues Grid */} +
+ {venues.map((venue) => ( +
handleViewVenue(venue._id)} + > + {/* Image */} +
+ {venue.coverImage && venue.coverImage.startsWith('https://') ? ( + {venue.name} + ) : ( +
+ )} + +
+ + {/* Content */} +
+

+ {venue.name} +

+

+ {venue.city} + {venue.district && `, ${venue.district}`} +

+ + {/* Rating */} +
+ + ⭐ {(venue.avgRating || 0).toFixed(1)} + + + ({venue.reviewCount || 0} reviews) + +
+ + {/* Capacity */} + {venue.maxCapacity && ( +

+ Capacity: {venue.maxCapacity} guests +

+ )} +
+
+ ))} +
+ + {/* Pagination */} + {pagination && pagination.totalPages > 1 && ( +
+ + +
+ Page {pagination.page} of {pagination.totalPages} +
+ + +
+ )} +
+
+ ); +} diff --git a/client/src/pages/home/components/FeaturedVenues.tsx b/client/src/pages/home/components/FeaturedVenues.tsx new file mode 100644 index 0000000000..b9c2deb835 --- /dev/null +++ b/client/src/pages/home/components/FeaturedVenues.tsx @@ -0,0 +1,69 @@ +import { Link } from 'react-router'; +import { Skeleton } from '@/components/ui/skeleton'; +import { CompactVenueCard } from '@/components/CompactVenueCard'; +import type { PublicVenue } from '@/types/venue.types'; + +interface FeaturedVenuesProps { + isLoading: boolean; + featuredVenues: PublicVenue[]; + togglingVenues: Set; + handleToggleWishlist: (venueId: string, e: React.MouseEvent) => Promise; +} + +export function FeaturedVenues({ + isLoading, + featuredVenues, + togglingVenues, + handleToggleWishlist, +}: FeaturedVenuesProps) { + return ( +
+
+
+
+

+ Featured Properties + On Our Listing +

+
+ + + View All Properties + +
+ +
+ {isLoading + ? Array.from({ length: 8 }).map((_, i) => ( +
+ +
+
+ + + +
+
+
+ )) + : featuredVenues + .slice(0, 8) + .map((venue: PublicVenue) => ( + + ))} +
+
+
+ ); +} diff --git a/client/src/pages/home/components/HeroSection.tsx b/client/src/pages/home/components/HeroSection.tsx new file mode 100644 index 0000000000..78e2d0245a --- /dev/null +++ b/client/src/pages/home/components/HeroSection.tsx @@ -0,0 +1,56 @@ +import { IoLocationOutline, IoSearch } from 'react-icons/io5'; +import heroImage from '@/assets/hero.png'; + +interface HeroSectionProps { + search: string; + setSearch: (search: string) => void; + handleSearch: () => void; +} + +export function HeroSection({ search, setSearch, handleSearch }: HeroSectionProps) { + return ( +
+
+ + Venue Hero + +
+

+ Find the Perfect Venue +
+ for Every Occasion +

+ +

+ Curated spaces for corporate events, weddings, and private gatherings. +

+ +
+
+ + + setSearch(e.target.value)} + className="w-full outline-none bg-transparent text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] text-sm md:text-lg" + /> +
+ + +
+
+
+ ); +} diff --git a/client/src/pages/home/featuredVenues.ts b/client/src/pages/home/featuredVenues.ts new file mode 100644 index 0000000000..bb52c10553 --- /dev/null +++ b/client/src/pages/home/featuredVenues.ts @@ -0,0 +1,46 @@ +export const featuredVenues = [ + { + id: 1, + name: 'Royal Palace Hall', + place: 'Edappally', + price: 1000, + district: 'Ernakulam', + guests: '500 Guests', + rating: 4.8, + image: + 'https://plus.unsplash.com/premium_photo-1664530452329-42682d3a73a7?w=800&auto=format&fit=crop&q=60', + }, + + { + id: 2, + name: 'Grand Celebration Hall', + place: 'Kottakkal', + price: 1000, + district: 'Malappuram', + guests: '300 Guests', + rating: 4.6, + image: 'https://images.unsplash.com/photo-1511578314322-379afb476865?w=800', + }, + + { + id: 3, + name: 'Skyline Event Space', + place: 'Mavoor', + price: 1000, + district: 'Kozhikode', + guests: '200 Guests', + rating: 4.7, + image: 'https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?w=800', + }, + + { + id: 4, + name: 'Green Garden Venue', + place: 'Guruvayur', + price: 1000, + district: 'Thrissur', + guests: '450 Guests', + rating: 4.9, + image: 'https://images.unsplash.com/photo-1517457373958-b7bdd4587205?w=800', + }, +]; diff --git a/client/src/pages/home/index.tsx b/client/src/pages/home/index.tsx new file mode 100644 index 0000000000..9b88600b95 --- /dev/null +++ b/client/src/pages/home/index.tsx @@ -0,0 +1,237 @@ +import { useNavigate } from 'react-router'; +import { useState, useCallback } from 'react'; +import { Link } from 'react-router'; +import { + IoBusinessOutline, + IoCameraOutline, + IoCafeOutline, + IoStarOutline, + IoHomeOutline, + IoLeafOutline, + IoCheckmarkCircle, +} from 'react-icons/io5'; + +import { useApiQuery } from '@/hooks/useApi'; +import { API_ENDPOINTS } from '@/constants'; +import { useToggleWishlist, useWishlistSync } from '@/hooks/useWishlist'; +import type { PublicVenue } from '@/types/venue.types'; + +import { HeroSection } from './components/HeroSection'; +import { FeaturedVenues } from './components/FeaturedVenues'; + +const VENUE_CATEGORIES = [ + { name: 'Wedding Halls', icon: IoStarOutline, value: 'Wedding Hall' }, + { name: 'Corporate Spaces', icon: IoBusinessOutline, value: 'Corporate Space' }, + { name: 'Party Lawns', icon: IoLeafOutline, value: 'Party Lawn' }, + { name: 'Banquet Halls', icon: IoCafeOutline, value: 'Banquet Hall' }, + { name: 'Studios', icon: IoCameraOutline, value: 'Studio' }, + { name: 'Resorts', icon: IoHomeOutline, value: 'Resort' }, +]; + +const HomePage = () => { + const [search, setSearch] = useState(''); + const [togglingVenues, setTogglingVenues] = useState>(new Set()); + + const navigate = useNavigate(); + + const { toggleWishlist: toggleWishlistFn } = useToggleWishlist(); + useWishlistSync(); + + const handleSearch = () => { + const trimmedSearch = search.trim(); + + if (trimmedSearch) { + navigate(`/explore?search=${encodeURIComponent(trimmedSearch)}`); + } else { + navigate('/explore'); + } + }; + + const handleCategoryClick = (category: string) => { + navigate(`/explore?venueTypes=${encodeURIComponent(category)}`); + }; + + const { + data: featuredVenues = [], + isLoading, + isSuccess, + } = useApiQuery(['featured-venues'], { + method: 'GET', + url: API_ENDPOINTS.FEATURED_VENUES, + }); + + const handleToggleWishlist = useCallback( + async (venueId: string, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setTogglingVenues((prev) => new Set([...prev, venueId])); + + try { + await toggleWishlistFn(venueId); + } finally { + setTogglingVenues((prev) => { + const next = new Set(prev); + next.delete(venueId); + return next; + }); + } + }, + [toggleWishlistFn] + ); + + return ( +
+ {/* Hero Section */} +
+ + + {/* Features Strip */} +
+
+
+ +

Verified Venues

+
+

+ Handpicked and verified spaces for your perfect event. +

+
+ +
+
+ +

Transparent Pricing

+
+

+ What you see is what you pay. No hidden fees. +

+
+ +
+
+ +

Instant Booking

+
+

+ Secure your date instantly with a seamless checkout. +

+
+
+
+ + {/* Separator */} +
+
+
+ + {/* Categories Showcase */} +
+
+
+

+ Browse by Category +

+

+ Find the perfect space tailored to your specific event needs +

+
+ +
+ {VENUE_CATEGORIES.map((cat, idx) => ( +
handleCategoryClick(cat.value)} + className="flex flex-col items-center justify-center p-6 bg-[var(--bg-tertiary)] rounded-2xl border border-[var(--bg-grey)] hover:shadow-md hover:border-[var(--bg-green)] transition cursor-pointer group" + > +
+ +
+ {cat.name} +
+ ))} +
+
+
+ + {/* Featured Venues Section — only shown when API succeeded with results */} + {(isLoading || (isSuccess && featuredVenues.length > 0)) && ( + + )} + + {/* Become a Host Section */} +
+
+
+

+ List Your Property on BookMyVenue +

+

+ Have a beautiful space? Join thousands of hosts who are earning by renting out their + venues for events, meetings, and parties. +

+
+
+ + Become a Host + +
+
+
+ + {/* Discover BookMyVenue Section */} +
+
+
+

+ Discover More About
Property Rental +

+

+ BookMyVenue provides a seamless experience for both guests looking to book the perfect + space and hosts wanting to maximize their property's potential. +

+ +
+ {[ + 'Verified venues with detailed amenities', + 'Transparent pricing with no hidden fees', + 'Secure payment and instant booking confirmation', + ].map((item, idx) => ( +
+ + {item} +
+ ))} +
+ + + Discover More + +
+
+
+ Beautiful event space +
+
+
+
+
+ ); +}; + +export default HomePage; diff --git a/client/src/pages/listVenue/ListVenueLayout.tsx b/client/src/pages/listVenue/ListVenueLayout.tsx new file mode 100644 index 0000000000..b89c03bc52 --- /dev/null +++ b/client/src/pages/listVenue/ListVenueLayout.tsx @@ -0,0 +1,18 @@ +import { Outlet } from 'react-router'; +import ListSidebar from '@/components/common/ListSidebar'; + +const ListVenueLayout = () => { + return ( +
+ {/* Sidebar */} + + + {/* Right Content */} +
+ +
+
+ ); +}; + +export default ListVenueLayout; diff --git a/client/src/pages/listVenue/addVenue/components/AmenitiesSection.tsx b/client/src/pages/listVenue/addVenue/components/AmenitiesSection.tsx new file mode 100644 index 0000000000..3eec3bcd9e --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/AmenitiesSection.tsx @@ -0,0 +1,25 @@ +import { Field, ErrorMessage } from 'formik'; +import { AMENITIES_LIST } from '@/constants/venueConstants'; +import { FORM_ERROR_CLASS } from '@/constants/uiClasses'; + +const err = FORM_ERROR_CLASS; + +export function AmenitiesSection() { + return ( +
+

Amenities

+
+ {AMENITIES_LIST.map((item) => ( + + ))} +
+ +
+ ); +} diff --git a/client/src/pages/listVenue/addVenue/components/BasicInfoStep.tsx b/client/src/pages/listVenue/addVenue/components/BasicInfoStep.tsx new file mode 100644 index 0000000000..b55b57818d --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/BasicInfoStep.tsx @@ -0,0 +1,184 @@ +import { Field, ErrorMessage } from 'formik'; +import { KERALA_DISTRICTS } from '@/constants'; +import { PlaceAutocomplete } from '@/components/PlaceAutocomplete'; +import { VENUE_TYPES, SPACE_ATTRIBUTES, SEATING_CONFIGURATIONS } from '@/constants/venueConstants'; + +const err = 'text-red-500 text-sm mt-1'; + +const BasicInfoStep = () => { + return ( +
+
+

Venue Registration

+

+ Add your venue details to help customers discover your space. +

+
+ +
+
+ {/* Venue Name */} +
+ + + +
+ + {/* Description */} +
+ + + +
+ + {/* Venue Type */} +
+ + + + {VENUE_TYPES.map((type) => ( + + ))} + + +
+ + {/* District */} +
+ + + + {KERALA_DISTRICTS.map((district) => ( + + ))} + + +
+ + {/* City */} +
+ + + +
+ + {/* Pincode */} +
+ + + +
+ + {/* Full Address */} +
+ + + +
+ + {/* Google Maps */} +
+ + + +
+
+ + {/* Space Attributes */} +
+

Space Attributes

+
+ {SPACE_ATTRIBUTES.map((item) => ( + + ))} +
+ +
+ + {/* Seating Configuration */} +
+

Seating Configuration

+
+ {SEATING_CONFIGURATIONS.map((item) => ( + + ))} +
+ +
+ + {/* Max Capacity */} +
+ + + +
+
+
+ ); +}; + +export default BasicInfoStep; diff --git a/client/src/pages/listVenue/addVenue/components/BasicValidation.ts b/client/src/pages/listVenue/addVenue/components/BasicValidation.ts new file mode 100644 index 0000000000..3794f48b81 --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/BasicValidation.ts @@ -0,0 +1,34 @@ +import * as Yup from 'yup'; + +export const basicInfoSchema = Yup.object({ + VenueName: Yup.string() + .trim() + .required('Venue name is required') + .min(3, 'Minimum 3 characters') + .max(100, 'Maximum 100 characters'), + + VenueDescription: Yup.string() + .trim() + .required('Description is required') + .min(10, 'Minimum 10 characters'), + + venueType: Yup.string().required('Venue type is required'), + + district: Yup.string().required('District is required'), + + city: Yup.string().required('City / Place is required'), + + pincode: Yup.string() + .matches(/^[0-9]{6}$/, 'Invalid pincode') + .required('Pincode is required'), + + fullAddress: Yup.string().required('Address is required').min(5, 'Minimum 5 characters'), + + googleMapsLink: Yup.string().url('Invalid Google Maps URL').nullable().optional(), + + spaceAttributes: Yup.array().min(1, 'Select at least one'), + + seatingConfigurations: Yup.array(), + + maxCapacity: Yup.number().typeError('Must be a number').positive('Must be positive').optional(), +}); diff --git a/client/src/pages/listVenue/addVenue/components/BookingTypeSection.tsx b/client/src/pages/listVenue/addVenue/components/BookingTypeSection.tsx new file mode 100644 index 0000000000..b4fc2701a4 --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/BookingTypeSection.tsx @@ -0,0 +1,44 @@ +import { Field, ErrorMessage } from 'formik'; +import { FORM_ERROR_CLASS } from '@/constants/uiClasses'; +import { DAYS_OF_WEEK } from '@/constants/common'; + +const err = FORM_ERROR_CLASS; + +export function BookingTypeSection() { + return ( + <> + {/* Booking Type */} +
+ +
+ + +
+ +
+ + {/* Working Days */} +
+

Working Days

+
+ {DAYS_OF_WEEK.map((day) => ( + + ))} +
+ +
+ + ); +} diff --git a/client/src/pages/listVenue/addVenue/components/FinishStep.tsx b/client/src/pages/listVenue/addVenue/components/FinishStep.tsx new file mode 100644 index 0000000000..1cadcad85e --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/FinishStep.tsx @@ -0,0 +1,298 @@ +import { Field, FieldArray, ErrorMessage, useFormikContext } from 'formik'; +import { useState, useEffect } from 'react'; +import { useToast } from '@/hooks/useToast'; +import { ALLOWED_IMAGE_TYPES, MAX_IMAGE_FILE_SIZE } from '@/constants/upload'; +import { FORM_ERROR_CLASS } from '@/constants/uiClasses'; +import { CANCELLATION_POLICIES, REFUND_TYPES } from '@/constants/bookingConstants'; + +type FinishStepValues = { + contact: { name: string; phone: string; email?: string }; + cancellation: { + policy: string; + refundType: string; + refundRules: { daysBefore: string; refundPercentage: string }[]; + }; + venuePhotos: File[]; + existingImages: { + coverImage: string; + galleryImages: string[]; + }; +}; + +const err = FORM_ERROR_CLASS; + +const FinishStep = () => { + const [previewImages, setPreviewImages] = useState([]); + const { values, setFieldValue } = useFormikContext(); + const { error: showError } = useToast(); + + useEffect(() => { + const urls = values.venuePhotos?.map((file) => URL.createObjectURL(file)) || []; + setPreviewImages(urls); + return () => { + urls.forEach((url) => URL.revokeObjectURL(url)); + }; + }, [values.venuePhotos]); + + const existingCover = values.existingImages?.coverImage || ''; + const existingGallery = values.existingImages?.galleryImages || []; + + const allDisplayItems: Array< + { type: 'existing'; url: string } | { type: 'new'; url: string; index: number } + > = [ + ...(existingCover ? [{ type: 'existing' as const, url: existingCover }] : []), + ...existingGallery.map((url) => ({ type: 'existing' as const, url })), + ...previewImages.map((url, i) => ({ type: 'new' as const, url, index: i })), + ]; + + const hasImages = allDisplayItems.length > 0; + + return ( +
+
+
+

Final Details

+

+ Upload photos and configure final venue settings. +

+
+ +
+ {/* Photos */} +
+

Venue Photos

+

+ Upload venue images. First image becomes cover photo. +

+ + {/* Combined Grid — existing images + new upload previews */} + {hasImages && ( +
+ {allDisplayItems.map((item, index) => ( +
+ + + {index === 0 && ( +
+ Cover +
+ )} + + +
+ ))} +
+ )} + + {/* Upload area */} + + + + + {previewImages.length > 0 && ( +

+ {previewImages.length} new image(s) selected +

+ )} +
+ + {/* Contact */} +
+

Contact Details

+
+
+ + + +
+
+ + + +
+
+
+ + {/* Cancellation Policy */} +
+

Cancellation Policy

+
+ + +
+ +
+ + {/* Refund Rules — only for refundable */} + {values.cancellation.policy === CANCELLATION_POLICIES.REFUNDABLE && ( +
+

Refund Rules

+

+ Configure refund percentage based on cancellation time. +

+ + {/* Refund Type */} +
+ +
+ + +
+ +
+ + {/* Refund Rules — only for time based */} + {values.cancellation.refundType === REFUND_TYPES.TIME_BASED && ( + + {({ push, remove }) => ( +
+ {values.cancellation.refundRules.map((_, index) => ( +
+
+
+ + + +
+
+ + + +
+
+ {index > 0 && ( + + )} +
+ ))} + + +
+ )} +
+ )} +
+ )} +
+
+
+ ); +}; + +export default FinishStep; diff --git a/client/src/pages/listVenue/addVenue/components/FinishValidation.ts b/client/src/pages/listVenue/addVenue/components/FinishValidation.ts new file mode 100644 index 0000000000..44f0d49106 --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/FinishValidation.ts @@ -0,0 +1,37 @@ +import * as Yup from 'yup'; +import { CANCELLATION_POLICIES, REFUND_TYPES } from '@/constants/bookingConstants'; + +export const finishSchema = Yup.object({ + contact: Yup.object({ + name: Yup.string().trim().required('Contact name is required'), + phone: Yup.string() + .matches(/^[6-9]\d{9}$/, 'Enter valid phone number') + .required('Phone number is required'), + email: Yup.string().email('Must be a valid email').optional(), + }), + cancellation: Yup.object({ + policy: Yup.string().required('Select cancellation policy'), + refundType: Yup.string().when('policy', { + is: CANCELLATION_POLICIES.REFUNDABLE, + then: (schema) => + schema.oneOf([REFUND_TYPES.FULL, REFUND_TYPES.TIME_BASED]).required('Select refund type'), + }), + refundRules: Yup.array().when(['policy', 'refundType'], { + is: (policy: string, type: string) => + policy === CANCELLATION_POLICIES.REFUNDABLE && type === REFUND_TYPES.TIME_BASED, + then: () => + Yup.array( + Yup.object({ + daysBefore: Yup.number().integer().min(0).required('Required'), + refundPercentage: Yup.number().min(0).max(100).required('Required'), + }) + ).min(1, 'Add at least one refund rule'), + }), + }), + venuePhotos: Yup.array().when('existingImages', { + is: (ei: { coverImage: string; galleryImages: string[] }) => + !ei?.coverImage && (!ei?.galleryImages || ei.galleryImages.length === 0), + then: (s) => s.min(1, 'Upload at least one photo'), + otherwise: (s) => s.notRequired(), + }), +}); diff --git a/client/src/pages/listVenue/addVenue/components/FixedBookingSection.tsx b/client/src/pages/listVenue/addVenue/components/FixedBookingSection.tsx new file mode 100644 index 0000000000..8e39ee60e4 --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/FixedBookingSection.tsx @@ -0,0 +1,111 @@ +import { Field, FieldArray, ErrorMessage } from 'formik'; +import { FORM_ERROR_CLASS, FORM_INPUT_CLASS } from '@/constants/uiClasses'; + +const err = FORM_ERROR_CLASS; +const inputCls = FORM_INPUT_CLASS; + +interface FixedBookingSectionProps { + fixedPackages: { slotName: string; startTime: string; endTime: string; price: string }[]; +} + +export function FixedBookingSection({ fixedPackages }: FixedBookingSectionProps) { + return ( +
+

Fixed Packages

+ + {({ push, remove }) => ( +
+ {(fixedPackages || []).map((_, index) => ( +
+
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+ + {index > 0 && ( + + )} +
+ ))} + + +
+ )} +
+
+ ); +} diff --git a/client/src/pages/listVenue/addVenue/components/FlexibleBookingSection.tsx b/client/src/pages/listVenue/addVenue/components/FlexibleBookingSection.tsx new file mode 100644 index 0000000000..26e71c59f6 --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/FlexibleBookingSection.tsx @@ -0,0 +1,291 @@ +import { Field, FieldArray, ErrorMessage } from 'formik'; +import { FORM_ERROR_CLASS, FORM_INPUT_CLASS } from '@/constants/uiClasses'; +import { PRICING_TYPES } from '@/constants/venueConstants'; + +const err = FORM_ERROR_CLASS; +const inputCls = FORM_INPUT_CLASS; + +interface FlexibleBookingSectionProps { + openTime?: string; + closeTime?: string; + pricingType: string; + pricingRules: { fromTime: string; toTime: string; price: string }[]; + blockedTimes: { fromTime: string; toTime: string }[]; +} + +export function FlexibleBookingSection({ + openTime, + closeTime, + pricingType, + pricingRules, + blockedTimes, +}: FlexibleBookingSectionProps) { + return ( +
+ {/* Working Hours */} +
+

Working Hours

+
+
+ + + +
+
+ + + +
+
+ {openTime && closeTime && ( +

+ ⏱ All time-based fields below are restricted to{' '} + + {openTime} – {closeTime} + +

+ )} +
+ + {/* Slot Duration & Buffer Time */} +
+
+ +

+ Minimum booking duration for each slot. +

+ + + + + + + + +
+ +
+ +

Time gap between each slot.

+ + + + + + + + + +
+
+ + {/* Pricing Type */} +
+ +
+ + +
+ +
+ + {/* Same Price */} + {pricingType === PRICING_TYPES.FIXED && ( +
+ + + +
+ )} + + {/* Time Based Pricing Rules */} + {pricingType === PRICING_TYPES.TIME_BASED && ( +
+

Pricing Rules

+
+
+ +

+ This base price applies when time-based pricing is active. +

+ + +
+
+ {openTime && closeTime && ( +

+ Times must be within working hours:{' '} + + {openTime} – {closeTime} + +

+ )} + + {({ push, remove }) => ( +
+ {(pricingRules || []).map((_, index) => ( +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ {index > 0 && ( + + )} +
+ ))} + +
+ )} +
+
+ )} + + {/* Blocked Times */} +
+

Blocked Time

+

+ Add maintenance, lunch break or unavailable hours. + {openTime && closeTime && ( + <> + {' '} + Times must be within{' '} + + {openTime} – {closeTime} + + . + + )} +

+ + {({ push, remove }) => ( +
+ {(blockedTimes || []).map((_, index) => ( +
+
+
+ + + +
+
+ + + +
+
+ {index > 0 && ( + + )} +
+ ))} + +
+ )} +
+
+
+ ); +} diff --git a/client/src/pages/listVenue/addVenue/components/SlotPreviewPanel.tsx b/client/src/pages/listVenue/addVenue/components/SlotPreviewPanel.tsx new file mode 100644 index 0000000000..530391d2b4 --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/SlotPreviewPanel.tsx @@ -0,0 +1,175 @@ +import React, { useState, useMemo } from 'react'; +import { Calendar } from '@/components/ui/calendar'; +import type { ISlotConfig, IPreviewSlot } from '@/utils/slotGenerator.types'; +import { + generateSlots, + isWorkingDay, + getNextWorkingDay, + formatDateToString, + parseDateString, +} from '@/utils/slotGenerator.utils'; + +type SlotPreviewPanelProps = ISlotConfig; + +const SlotPreviewPanel: React.FC = ({ + bookingType, + workingDays, + workingHours, + fixedPackages, + slotDuration, + bufferTime, + pricingType, + pricingRules, + blockedTimes, + samePrice, + basePrice, +}) => { + const [selectedPreviewDate, setSelectedPreviewDate] = useState(() => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const nextWorkingDay = getNextWorkingDay(tomorrow, workingDays); + return formatDateToString(nextWorkingDay); + }); + + const slots = useMemo(() => { + if (!selectedPreviewDate) return []; + + const config: ISlotConfig = { + bookingType, + workingDays, + workingHours, + fixedPackages, + slotDuration, + bufferTime, + pricingType, + pricingRules, + blockedTimes, + samePrice, + basePrice, + }; + + return generateSlots(selectedPreviewDate, config); + }, [ + selectedPreviewDate, + bookingType, + workingDays, + workingHours, + fixedPackages, + slotDuration, + bufferTime, + pricingType, + pricingRules, + blockedTimes, + samePrice, + basePrice, + ]); + + const handleDateSelect = (date: Date | undefined) => { + if (date) { + setSelectedPreviewDate(formatDateToString(date)); + } + }; + + const isDateDisabled = (date: Date): boolean => { + return !isWorkingDay(date, workingDays); + }; + + const displayDate = selectedPreviewDate + ? parseDateString(selectedPreviewDate).toLocaleDateString('en-IN', { + weekday: 'short', + day: '2-digit', + month: 'short', + year: 'numeric', + }) + : ''; + + const bookingTypeLabel = bookingType === 'fixedBooking' ? 'Fixed Package' : 'Flexible Booking'; + + return ( +
+ {/* Booking Type Badge */} +
+

Preview

+ + {bookingTypeLabel} + +
+ + {/* Calendar Section */} +
+ +
+ +
+
+ + {/* Selected Date Pill */} + {displayDate && ( +
+
+ {displayDate} +
+
+ )} + + {/* Available Slots Section */} +
+

Available Slots

+ + {!selectedPreviewDate ? ( +
+ Select a date to see slots +
+ ) : slots.length === 0 ? ( +
+ No availability configured yet +
+ ) : ( +
+ {slots.map((slot: IPreviewSlot) => ( + + ))} +
+ )} +
+ + {/* Disclaimer Note */} +
+

+ * Note: This preview is for visualization purposes only. Actual slot generation is handled + server-side and may evaluate additional constraints and edge cases. +

+
+
+ ); +}; + +export default SlotPreviewPanel; diff --git a/client/src/pages/listVenue/addVenue/components/middleStep.tsx b/client/src/pages/listVenue/addVenue/components/middleStep.tsx new file mode 100644 index 0000000000..79fbae2747 --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/middleStep.tsx @@ -0,0 +1,111 @@ +import { useFormikContext } from 'formik'; +import { BOOKING_TYPES } from '@/constants/venueConstants'; +import SlotPreviewPanel from './SlotPreviewPanel'; +import { BookingTypeSection } from './BookingTypeSection'; +import { FixedBookingSection } from './FixedBookingSection'; +import { FlexibleBookingSection } from './FlexibleBookingSection'; +import { AmenitiesSection } from './AmenitiesSection'; + +type BookingStepValues = { + bookingType: string; + pricingType: string; + fixedPackages: { + slotName: string; + startTime: string; + endTime: string; + price: string; + }[]; + workingDays: string[]; + workingHours: { + open: string; + close: string; + }; + slotDuration: string; + bufferTime: string; + samePrice: string; + pricingRules: { + fromTime: string; + toTime: string; + price: string; + }[]; + blockedTimes: { + fromTime: string; + toTime: string; + }[]; + amenities: string[]; + pricing?: { + basePrice?: string; + }; +}; + +const BookingStep = () => { + const { values } = useFormikContext(); + + const openTime = values.workingHours?.open || undefined; + const closeTime = values.workingHours?.close || undefined; + + return ( +
+
+
+
+
+
+

Booking Configuration

+

+ Configure booking, pricing and venue setup. +

+
+ +
+ + + {values.bookingType === BOOKING_TYPES.FIXED && ( + + )} + + {values.bookingType === BOOKING_TYPES.FLEXIBLE && ( + + )} + + +
+
+
+ +
+
+ ({ + ...pkg, + price: parseFloat(pkg.price) || 0, + }))} + slotDuration={values.slotDuration} + bufferTime={values.bufferTime} + pricingType={values.pricingType} + pricingRules={(values.pricingRules || []).map((rule) => ({ + ...rule, + price: parseFloat(rule.price) || 0, + }))} + blockedTimes={values.blockedTimes || []} + samePrice={values.samePrice} + basePrice={values.pricing?.basePrice} + /> +
+
+
+
+
+ ); +}; + +export default BookingStep; diff --git a/client/src/pages/listVenue/addVenue/components/middleValidation.ts b/client/src/pages/listVenue/addVenue/components/middleValidation.ts new file mode 100644 index 0000000000..7deb671c63 --- /dev/null +++ b/client/src/pages/listVenue/addVenue/components/middleValidation.ts @@ -0,0 +1,251 @@ +import * as Yup from 'yup'; + +// HH:MM → total minutes since midnight for easy comparison +const toMinutes = (t: string): number => { + const [h, m] = t.split(':').map(Number); + return h * 60 + m; +}; + +export const middleSchema = Yup.object({ + bookingType: Yup.string().required('Select booking type'), + + /* Working Days */ + workingDays: Yup.array().min(1, 'Select at least one working day'), + + /* Fixed Booking */ + fixedPackages: Yup.array().when('bookingType', { + is: 'fixedBooking', + then: () => + Yup.array() + .min(1, 'Add at least one package') + .of( + Yup.object({ + slotName: Yup.string().trim().required('Slot name required'), + startTime: Yup.string().required('Start time required'), + endTime: Yup.string().required('End time required'), + price: Yup.number() + .typeError('Enter valid price') + .required('Price required') + .positive('Must be positive'), + }).test('fixed-pkg-times', 'End time must be after start time', function (value) { + const { startTime, toTime } = value as { + startTime?: string; + toTime?: string; + }; + if (startTime && toTime && toMinutes(toTime) <= toMinutes(startTime)) { + return this.createError({ + path: `${this.path}.endTime`, + message: 'End time must be after start time', + }); + } + return true; + }) + ), + otherwise: () => Yup.array(), + }), + + /* Flexible Booking */ + workingHours: Yup.object().when('bookingType', { + is: 'flexibleBooking', + then: () => + Yup.object({ + open: Yup.string().required('Open time required'), + close: Yup.string().required('Close time required'), + }).test('open-before-close', 'Close time must be after open time', function (value) { + const { open, close } = value as { open?: string; close?: string }; + if (open && close && toMinutes(close) <= toMinutes(open)) { + return this.createError({ + path: `${this.path}.close`, + message: 'Close time must be after open time', + }); + } + return true; + }), + otherwise: () => Yup.object(), + }), + + slotDuration: Yup.string().when('bookingType', { + is: 'flexibleBooking', + then: (schema) => schema.required('Select slot duration'), + }), + + bufferTime: Yup.string().when('bookingType', { + is: 'flexibleBooking', + then: (schema) => schema.required('Select buffer time'), + }), + + pricingType: Yup.string().when('bookingType', { + is: 'flexibleBooking', + then: (schema) => schema.required('Select pricing type'), + }), + + pricing: Yup.object().when('pricingType', { + is: 'timeBasedPricing', + then: () => + Yup.object({ + basePrice: Yup.number() + .typeError('Enter valid base price') + .required('Enter base price') + .positive('Must be positive'), + }).required('Base price is required'), + otherwise: () => Yup.object(), + }), + + /* Same Price */ + samePrice: Yup.string().when('pricingType', { + is: 'fixedPricing', + then: (schema) => schema.required('Enter slot price'), + }), + + /* + * pricingRules — validate each row's times stay within workingHours. + * Uses object-level .test() to avoid Yup cyclic-dependency on sibling refs. + */ + pricingRules: Yup.array().when('pricingType', { + is: 'timeBasedPricing', + then: () => + Yup.array() + .min(1, 'Add at least one pricing rule') + .of( + Yup.object({ + fromTime: Yup.string().required('Required'), + toTime: Yup.string().required('Required'), + price: Yup.number() + .typeError('Enter valid price') + .required('Required') + .positive('Must be positive'), + }).test('pricing-rule-times', 'Invalid pricing rule times', function (value) { + const { fromTime, toTime } = value as { + fromTime?: string; + toTime?: string; + }; + + // toTime must be after fromTime + if (fromTime && toTime && toMinutes(toTime) <= toMinutes(fromTime)) { + return this.createError({ + path: `${this.path}.toTime`, + message: 'To time must be after from time', + }); + } + + // Times must stay within workingHours + const workingHours = ( + this.options as { + context?: { workingHours?: { open: string; close: string } }; + } + ).context?.workingHours; + const open = + workingHours?.open ?? + ( + this as unknown as { + from?: { value: { workingHours?: { open?: string } } }[]; + } + ).from?.[2]?.value?.workingHours?.open; + const close = + workingHours?.close ?? + ( + this as unknown as { + from?: { value: { workingHours?: { close?: string } } }[]; + } + ).from?.[2]?.value?.workingHours?.close; + + if (open && close) { + if (fromTime && toMinutes(fromTime) < toMinutes(open)) { + return this.createError({ + path: `${this.path}.fromTime`, + message: `Must be on or after open time (${open})`, + }); + } + if (toTime && toMinutes(toTime) > toMinutes(close)) { + return this.createError({ + path: `${this.path}.toTime`, + message: `Must be on or before close time (${close})`, + }); + } + } + return true; + }) + ), + otherwise: () => Yup.array(), + }), + + /* + * blockedTimes — optional rows, but if filled they must stay within workingHours. + * Object-level test to avoid fromTime ↔ toTime cyclic dependency. + */ + blockedTimes: Yup.array().when('bookingType', { + is: 'flexibleBooking', + then: () => + Yup.array().of( + Yup.object({ + fromTime: Yup.string(), + toTime: Yup.string(), + }).test( + 'blocked-times-pair-and-range', + 'Both From Time and To Time are required together and must stay within working hours', + function (value) { + const { fromTime, toTime } = value as { + fromTime?: string; + toTime?: string; + }; + + // Pair check + if (fromTime && !toTime) { + return this.createError({ + path: `${this.path}.toTime`, + message: 'To time required', + }); + } + if (!fromTime && toTime) { + return this.createError({ + path: `${this.path}.fromTime`, + message: 'From time required', + }); + } + + // If both filled — validate order and bounds + if (fromTime && toTime) { + if (toMinutes(toTime) <= toMinutes(fromTime)) { + return this.createError({ + path: `${this.path}.toTime`, + message: 'To time must be after from time', + }); + } + + // Walk up the form tree to get workingHours + const formValues = ( + this as unknown as { + from?: { + value: { workingHours?: { open?: string; close?: string } }; + }[]; + } + ).from; + const open = formValues?.[1]?.value?.workingHours?.open; + const close = formValues?.[1]?.value?.workingHours?.close; + + if (open && close) { + if (toMinutes(fromTime) < toMinutes(open)) { + return this.createError({ + path: `${this.path}.fromTime`, + message: `Must be on or after open time (${open})`, + }); + } + if (toMinutes(toTime) > toMinutes(close)) { + return this.createError({ + path: `${this.path}.toTime`, + message: `Must be on or before close time (${close})`, + }); + } + } + } + + return true; + } + ) + ), + otherwise: () => Yup.array(), + }), + + /* Amenities */ + amenities: Yup.array().min(1, 'Select at least one amenity'), +}); diff --git a/client/src/pages/listVenue/addVenue/index.tsx b/client/src/pages/listVenue/addVenue/index.tsx new file mode 100644 index 0000000000..b7ef2a117a --- /dev/null +++ b/client/src/pages/listVenue/addVenue/index.tsx @@ -0,0 +1,452 @@ +import { Formik, Form } from 'formik'; +import { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useAuth } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/useToast'; +import { useImageUpload } from '@/hooks/useImageUpload'; +import Spinner from '@/components/common/Spinner'; +import { + createVenue, + updateVenue, + submitVenue, + getVenueById, + getMyDraft, + upsertVenueDraft, +} from '@/services/venueService'; +import { mapFormToDTO, mapVenueToForm } from '@/utils/venueFormMapper'; +import { + saveDraftSession, + loadDraftSession, + clearDraftSession, + clearDraft, +} from '@/utils/venueDraft'; +import type { AddVenueFormValues } from '@/types/venue.types'; + +import BasicInfoStep from './components/BasicInfoStep'; +import { basicInfoSchema } from './components/BasicValidation'; +import BookingStep from './components/middleStep'; +import FinishStep from './components/FinishStep'; +import { finishSchema } from './components/FinishValidation'; +import { middleSchema } from './components/middleValidation'; + +const BLANK_FORM: AddVenueFormValues = { + VenueName: '', + VenueDescription: '', + venueType: '', + district: '', + state: '', + city: '', + pincode: '', + fullAddress: '', + googleMapsLink: '', + coordinates: null, + spaceAttributes: [], + seatingConfigurations: [], + maxCapacity: '', + bookingType: '', + workingDays: [], + fixedPackages: [{ slotName: '', startTime: '', endTime: '', price: 0 }], + workingHours: { open: '', close: '' }, + flexibleBooking: { slotDuration: '', bufferTime: '' }, + pricing: { + pricingType: '', + basePrice: 0, + pricingRules: [{ fromTime: '', toTime: '', price: 0 }], + }, + blockedTimes: [{ fromTime: '', toTime: '', reason: '' }], + amenities: [], + venuePhotos: [], + existingImages: { + coverImage: '', + galleryImages: [], + }, + contact: { name: '', phone: '', email: '' }, + cancellation: { + policy: '', + refundType: '', + refundRules: [{ daysBefore: '', refundPercentage: '' }], + }, +}; + +const STEP_LABELS = ['Basic Info', 'Booking', 'Final Details']; + +const AddVenue = () => { + const { venueId } = useParams<{ venueId?: string }>(); + const isEditMode = !!venueId; + const [step, setStep] = useState(0); + const [formValues, setFormValues] = useState(BLANK_FORM); + const [initializing, setInitializing] = useState(true); + const [venueData, setVenueData] = useState<{ + _id: string; + currentEditDeadline?: string; + submissionCount?: number; + status: string; + } | null>(null); + const [now, setNow] = useState(new Date()); + const [isNavigating, setIsNavigating] = useState(false); + + const navigate = useNavigate(); + const { user, loading: authLoading } = useAuth(); + const { success: showSuccess, error: showError } = useToast(); + const { uploadFiles, isUploading } = useImageUpload(); + + const stepSchemas = [basicInfoSchema, middleSchema, finishSchema]; + + // Update now every minute for deadline calculations + useEffect(() => { + const interval = setInterval(() => setNow(new Date()), 60000); + return () => clearInterval(interval); + }, []); + + // Determine if deadline has passed + const isDeadlinePassed = venueData?.currentEditDeadline + ? now > new Date(venueData.currentEditDeadline) + : false; + + // Calculate days left + const daysLeft = + venueData?.currentEditDeadline && !isDeadlinePassed + ? Math.max( + 0, + Math.ceil( + (new Date(venueData.currentEditDeadline).getTime() - now.getTime()) / + (24 * 60 * 60 * 1000) + ) + ) + : 0; + + // Load venue data for edit mode + useEffect(() => { + if (isEditMode && venueId) { + const fetchVenue = async () => { + try { + const venue = await getVenueById(venueId); + setVenueData({ + _id: venue._id, + currentEditDeadline: venue.currentEditDeadline, + submissionCount: venue.submissionCount, + status: venue.status, + }); + setFormValues(mapVenueToForm(venue)); + setInitializing(false); + } catch (err) { + console.error('Failed to load venue:', err); + showError('Failed to load venue'); + setInitializing(false); + navigate('/list-venue/my-venues'); + } + }; + void fetchVenue(); + } else { + // New venue mode - load draft + const init = async () => { + if (authLoading) return; + if (!user?.id) { + setInitializing(false); + return; + } + + const session = loadDraftSession(user.id); + if (session && session.formValues) { + console.log('Restoring draft from session:', session.formValues); + setStep(session.step || 0); + setFormValues({ ...BLANK_FORM, ...session.formValues }); + setInitializing(false); + showSuccess('Draft retrieved, you can continue from here.'); + return; + } + + try { + const draft = await getMyDraft(); + console.log('Draft from API:', draft); + if (draft && draft.formValues && Object.keys(draft.formValues).length > 0) { + console.log('Restoring draft from API:', draft.formValues); + const values = { ...BLANK_FORM, ...(draft.formValues as Partial) }; + const resumeStep = typeof draft.step === 'number' ? draft.step : 0; + + setStep(resumeStep); + setFormValues(values); + saveDraftSession(user.id, { venueId: 'draft', step: resumeStep, formValues: values }); + showSuccess('Draft retrieved, you can continue from here.'); + } + } catch (err) { + console.error('Failed to get draft:', err); + } finally { + setInitializing(false); + } + }; + + void init(); + } + }, [venueId, isEditMode, user?.id, authLoading, showSuccess, showError, navigate]); + + if (initializing) { + return ( +
+
+ +

Loading…

+
+
+ ); + } + + return ( + { + if (!user?.id) { + showError('User not authenticated. Please restart.'); + return; + } + try { + setSubmitting(true); + + let newImageUrls: string[] = []; + if (values.venuePhotos?.length > 0) { + newImageUrls = await uploadFiles(values.venuePhotos); + } + + const remainingExisting = [ + values.existingImages.coverImage, + ...values.existingImages.galleryImages, + ].filter(Boolean) as string[]; + + const allImageUrls = [...remainingExisting, ...newImageUrls]; + const dto = mapFormToDTO(values, allImageUrls); + + if (isEditMode && venueId) { + // Update existing venue + await updateVenue(venueId, dto); + // Then submit for review + await submitVenue(venueId); + + clearDraftSession(user.id); + clearDraft(user.id); + showSuccess('Venue resubmitted for review successfully!'); + } else { + // Create new venue + await createVenue(dto); + + clearDraftSession(user.id); + clearDraft(user.id); + showSuccess('Venue submitted for review successfully!'); + } + + navigate('/list-venue/my-venues'); + } catch (error) { + console.error('Submission failed:', error); + showError('Submission failed. Please try again.'); + } finally { + setSubmitting(false); + } + }} + > + {({ validateForm, setTouched, values, errors, isSubmitting, submitForm }) => { + const hasErrors = Object.keys(errors).length > 0; + + const handleNext = async () => { + if (isNavigating) return; + setIsNavigating(true); + try { + const errs = await validateForm(); + const touchAll = (obj: Record): Record => + Object.keys(obj).reduce>((acc, key) => { + const val = obj[key]; + acc[key] = + val && typeof val === 'object' && !Array.isArray(val) + ? touchAll(val as Record) + : Array.isArray(val) + ? (val as unknown[]).map((item) => + item && typeof item === 'object' + ? touchAll(item as Record) + : true + ) + : true; + return acc; + }, {}); + + if (Object.keys(errs).length > 0) { + await setTouched(touchAll(errs) as never, false); + return; + } + + if (!user?.id) { + showError('User not authenticated'); + return; + } + + if (!isEditMode) { + await upsertVenueDraft(step + 1, values); + saveDraftSession(user.id, { venueId: 'draft', step: step + 1, formValues: values }); + } + setStep((s) => s + 1); + } catch (error) { + console.error('Save failed:', error); + showError('Failed to save progress. Please try again.'); + } finally { + setIsNavigating(false); + } + }; + + return ( +
+
+ {STEP_LABELS.map((label, i) => ( +
+ + {i < step ? '✓' : i + 1} + + + {label} + + {i < STEP_LABELS.length - 1 && ( + + )} +
+ ))} +
+ + {/* Deadline banner for edit mode */} + {isEditMode && venueData?.currentEditDeadline && ( +
+
+ {isDeadlinePassed ? ( + + + + + + ) : ( + + + + + + )} +
+

+ {isDeadlinePassed + ? 'Edit Window Expired' + : `⏰ ${daysLeft} day(s) left to resubmit`} +

+

+ Deadline:{' '} + {new Date(venueData.currentEditDeadline!).toLocaleDateString('en-IN', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + . + {isDeadlinePassed + ? ' This venue has been auto-suspended and cannot be resubmitted.' + : ' After this, the venue will be auto-suspended.'} +

+
+
+
+ )} + + {step === 0 && } + {step === 1 && } + {step === 2 && } + +
+ {step > 0 ? ( + + ) : ( +
+ )} + + {hasErrors && ( +
+

+ Please fix the errors before continuing +

+

+ Errors in: {Object.keys(errors).join(', ')} +

+
+ )} + + {step < 2 ? ( + + ) : ( + + )} +
+ + ); + }} + + ); +}; + +export default AddVenue; diff --git a/client/src/pages/listVenue/myVenue/components/VenueCard.tsx b/client/src/pages/listVenue/myVenue/components/VenueCard.tsx new file mode 100644 index 0000000000..1e314a6a56 --- /dev/null +++ b/client/src/pages/listVenue/myVenue/components/VenueCard.tsx @@ -0,0 +1,236 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import { Button } from '@/components/ui/button'; +import { DASHBOARD_URL } from '@/constants'; +import type { MyVenue } from '@/types/venue.types'; +import { MdOutlineLocationOn } from 'react-icons/md'; +import { AlertTriangle, AlertCircle } from 'lucide-react'; +import { format, differenceInDays } from 'date-fns'; +import { RejectionHistoryModal } from '@/components/explore/RejectionHistoryModal'; +import VenuePreviewModal from './VenuePreviewModal'; + +interface VenueCardProps { + venue: MyVenue; +} + +const getStatusBadge = (status: MyVenue['status']) => { + switch (status) { + case 'Draft': + return ( + + Draft + + ); + case 'PendingReview': + return ( + + Under Review + + ); + case 'Approved': + return ( + + Approved + + ); + case 'Rejected': + return ( + + Rejected + + ); + case 'Suspended': + return ( + + Suspended + + ); + default: + return null; + } +}; + +const getPreviewButtonLabel = (status: MyVenue['status']): string => { + switch (status) { + case 'PendingReview': + return 'View Submission'; + case 'Rejected': + return 'View Submission'; + case 'Suspended': + return 'View Details'; + case 'Draft': + return 'Preview'; + default: + return 'View Details'; + } +}; + +const VenueCard = ({ venue }: VenueCardProps) => { + const [previewOpen, setPreviewOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + + const isDeadlinePassed = + venue.currentEditDeadline && new Date() > new Date(venue.currentEditDeadline); + const daysLeft = venue.currentEditDeadline + ? differenceInDays(new Date(venue.currentEditDeadline), new Date()) + : null; + + const isApproved = venue.status === 'Approved'; + + const latestReason = + venue.rejectionReason ?? + venue.rejectionHistory?.[venue.rejectionHistory.length - 1]?.reason ?? + ''; + const truncatedReason = + latestReason.length > 10 ? latestReason.slice(0, 10) + '…' : latestReason || 'N/A'; + + return ( + <> +
+
+ {venue.coverImage ? ( + {venue.name} + ) : ( +
+ No Image +
+ )} +
{getStatusBadge(venue.status)}
+
+ + {venue.venueType || 'Venue'} + +
+
+ +
+

+ {venue.name} + {venue.isFeatured && ( + + ★ + + )} +

+ +
+ + + {venue.city}, {venue.district ? `${venue.district}, ` : ''} + {venue.state} + +
+ + {venue.status === 'Rejected' && + venue.rejectionHistory && + venue.rejectionHistory.length > 0 && ( + + )} + + {venue.status === 'Rejected' && venue.currentEditDeadline && ( +
+ {isDeadlinePassed ? ( + + ) : ( + + )} + + {isDeadlinePassed + ? 'Deadline passed' + : `${daysLeft} day${daysLeft !== 1 ? 's' : ''} left`} + + {!isDeadlinePassed && ( + + · {format(new Date(venue.currentEditDeadline), 'PP')} + + )} +
+ )} + +
+ {/* View Details / Submission button — Approved links to public page, others open modal */} + {isApproved ? ( + + ) : ( + + )} + + {venue.status === 'Approved' && ( + + )} + + {venue.status === 'Rejected' && + !isDeadlinePassed && + venue.submissionCount && + venue.submissionCount < 10 && ( + + )} + + {venue.status === 'Rejected' && + venue.submissionCount && + venue.submissionCount >= 10 && ( +

+ Max retries (10) exceeded +

+ )} +
+
+
+ + {/* Preview modal — only mounted for non-Approved venues */} + {!isApproved && ( + + )} + + + + ); +}; + +export default VenueCard; diff --git a/client/src/pages/listVenue/myVenue/components/VenuePreviewModal.tsx b/client/src/pages/listVenue/myVenue/components/VenuePreviewModal.tsx new file mode 100644 index 0000000000..46a94ce391 --- /dev/null +++ b/client/src/pages/listVenue/myVenue/components/VenuePreviewModal.tsx @@ -0,0 +1,646 @@ +import { Link } from 'react-router'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useApiQuery } from '@/hooks/useApi'; +import { API_ENDPOINTS } from '@/constants'; +import type { MyVenue, VenueDetail } from '@/types/venue.types'; +import { + Clock, + XCircle, + ShieldOff, + FileText, + AlertTriangle, + AlertCircle, + MapPin, + Users, + Tag, + Layers, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import { format, differenceInDays } from 'date-fns'; +import { useState, useEffect } from 'react'; + +// Status banner config + +interface StatusConfig { + icon: React.ReactNode; + accentClass: string; // border color + bannerBg: string; + badgeBg: string; + badgeText: string; + badgeBorder?: string; + title: string; + message: string; +} + +const getStatusConfig = (venue: MyVenue): StatusConfig => { + const isDeadlinePassed = + venue.currentEditDeadline && new Date() > new Date(venue.currentEditDeadline); + + switch (venue.status) { + case 'PendingReview': + return { + icon: , + accentClass: 'border-amber-200 dark:border-amber-900/50', + bannerBg: 'bg-amber-50/50 dark:bg-amber-900/10', + badgeBg: 'bg-amber-100 dark:bg-amber-900/30', + badgeBorder: 'border-amber-200 dark:border-amber-800/50', + badgeText: 'text-amber-700 dark:text-amber-400', + title: 'Under Review', + message: + "Your venue is under review by our admin team. You'll be notified by email once a decision is made.", + }; + case 'Rejected': + return { + icon: , + accentClass: 'border-red-200 dark:border-red-900/50', + bannerBg: 'bg-red-50/50 dark:bg-red-900/10', + badgeBg: 'bg-red-100 dark:bg-red-900/30', + badgeBorder: 'border-red-200 dark:border-red-800/50', + badgeText: 'text-red-700 dark:text-red-400', + title: isDeadlinePassed ? 'Rejected — Deadline Passed' : 'Rejected', + message: isDeadlinePassed + ? 'The edit window has expired. This venue has been auto-suspended.' + : 'Please address the feedback and resubmit before your deadline.', + }; + case 'Suspended': + return { + icon: , + accentClass: 'border-red-200 dark:border-red-900/50', + bannerBg: 'bg-red-50/50 dark:bg-red-900/10', + badgeBg: 'bg-red-100 dark:bg-red-900/30', + badgeBorder: 'border-red-200 dark:border-red-800/50', + badgeText: 'text-red-700 dark:text-red-400', + title: 'Suspended', + message: + 'This venue is suspended and not publicly visible. Contact support if you believe this is an error.', + }; + case 'Draft': + default: + return { + icon: , + accentClass: 'border-[var(--bg-grey)]', + bannerBg: 'bg-[var(--bg-primary)]', + badgeBg: 'bg-[var(--bg-grey)]/40', + badgeBorder: 'border-[var(--bg-grey)]', + badgeText: 'text-[var(--text-secondary)]', + title: 'Draft', + message: 'Draft preview. Complete all required fields and submit for review.', + }; + } +}; + +// Image strip +const CompactGallery = ({ images, venueName }: { images: string[]; venueName: string }) => { + const [idx, setIdx] = useState(0); + if (images.length === 0) return null; + + return ( +
+ {`${venueName} +
+ + + {idx + 1} / {images.length} + + + {images.length > 1 && ( + <> + + + + )} +
+ ); +}; + +// Venue detail chips +const DetailChip = ({ + icon, + label, + onClick, + className, +}: { + icon: React.ReactNode; + label: string; + onClick?: () => void; + className?: string; +}) => ( +
+ {icon} + {label} +
+); + +// Pricing View + +const PricingView = ({ venue, onBack }: { venue: VenueDetail; onBack: () => void }) => { + return ( +
+
+ +

Pricing & Working Hours

+
+ +
+ {/* Working Hours */} + {venue.workingHours && venue.workingDays && venue.workingDays.length > 0 && ( +
+

+ Working Hours +

+
+ + {venue.workingDays.join(', ')} + + + {venue.workingHours.open} - {venue.workingHours.close} + +
+
+ )} + + {/* Pricing Details */} +
+

+ {venue.bookingType === 'fixedBooking' ? 'Fixed Packages' : 'Flexible Pricing'} +

+ + {venue.bookingType === 'fixedBooking' && + venue.fixedPackages && + venue.fixedPackages.length > 0 ? ( +
+ {venue.fixedPackages.map((pkg, i) => ( +
+
+

+ {pkg.slotName} +

+

+ {pkg.startTime} - {pkg.endTime} +

+
+
+

₹{pkg.price}

+
+
+ ))} +
+ ) : venue.bookingType === 'flexibleBooking' && venue.pricing ? ( +
+
+ Base Price + + ₹{venue.pricing.basePrice}{' '} + {venue.pricing.pricingType === 'timeBasedPricing' ? '/ hr' : '/ day'} + +
+ + {venue.pricing.pricingType === 'timeBasedPricing' && + venue.pricing.pricingRules && + venue.pricing.pricingRules.length > 0 && ( +
+ + + + + + + + + {venue.pricing.pricingRules.map((rule, i) => ( + + + + + ))} + +
Time WindowPrice / hr
+ {rule.fromTime} - {rule.toTime} + + ₹{rule.price} +
+
+ )} +
+ ) : ( +

+ No pricing details configured. +

+ )} +
+
+
+ ); +}; + +// Skeleton + +const ModalSkeleton = () => ( +
+ +
+ + + + +
+ +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+); + +// Rejection History Sub-View + +const RejectionHistoryView = ({ myVenue, onBack }: { myVenue: MyVenue; onBack: () => void }) => { + const history = myVenue.rejectionHistory || []; + return ( +
+
+ +

+ Rejection History ({history.length}/10) +

+
+ +
+ {history.length === 0 ? ( +

+ No rejection history available. +

+ ) : ( + history + .slice() + .reverse() + .map((entry, idx) => ( +
+
+ Attempt #{entry.submissionNumber} + Deadline: {format(new Date(entry.editDeadline), 'PPp')} +
+ {entry.extendedAt && ( +

+ Extended on {format(new Date(entry.extendedAt), 'PPp')} +

+ )} +

+ {entry.reason} +

+
+ )) + )} +
+
+ ); +}; + +// Action button per status + +const StatusAction = ({ venue }: { venue: MyVenue }) => { + const isDeadlinePassed = + venue.currentEditDeadline && new Date() > new Date(venue.currentEditDeadline); + + if (venue.status === 'Rejected' && !isDeadlinePassed && (venue.submissionCount ?? 0) < 10) { + return ( + + ); + } + if (venue.status === 'Draft') { + return ( + + ); + } + if (venue.status === 'PendingReview') { + return ( + + ⏳ Awaiting review + + ); + } + return null; +}; + +// Main + +interface VenuePreviewModalProps { + venueId: string; + myVenue: MyVenue; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const VenuePreviewModal = ({ venueId, myVenue, open, onOpenChange }: VenuePreviewModalProps) => { + const { data: venue, isLoading } = useApiQuery( + ['venue-preview', venueId], + { url: API_ENDPOINTS.VENUE_BY_ID(venueId), method: 'GET' }, + { enabled: open } + ); + + const [view, setView] = useState<'main' | 'pricing' | 'history'>('main'); + + useEffect(() => { + if (!open) { + setTimeout(() => setView('main'), 300); + } + }, [open]); + + const statusConfig = getStatusConfig(myVenue); + const isDeadlinePassed = + myVenue.currentEditDeadline && new Date() > new Date(myVenue.currentEditDeadline); + const daysLeft = myVenue.currentEditDeadline + ? differenceInDays(new Date(myVenue.currentEditDeadline), new Date()) + : null; + + const images = venue?.coverImage + ? [venue.coverImage, ...(venue.galleryImages ?? [])] + : myVenue.coverImage + ? [myVenue.coverImage] + : []; + + return ( + + + {/* Fixed Header (Status Banner) */} +
+ + {myVenue.name} — {statusConfig.title} + + +
+
+ {statusConfig.icon} +
+
+
+ + {statusConfig.title} + +

+ {myVenue.name} +

+
+

+ {statusConfig.message} +

+
+
+ + {/* Rejection deadline */} + {myVenue.status === 'Rejected' && myVenue.currentEditDeadline && ( +
+ {isDeadlinePassed ? ( + + ) : ( + + )} + + {isDeadlinePassed + ? 'Deadline passed' + : `${daysLeft} day${daysLeft !== 1 ? 's' : ''} left`} + + · + {format(new Date(myVenue.currentEditDeadline), 'dd MMM yyyy')} +
+ )} + + {/* Suspension reason */} + {myVenue.status === 'Suspended' && myVenue.suspensionReason && ( +
+ + + Reason: + {myVenue.suspensionReason} + +
+ )} +
+ + {/* Scrollable Body */} +
+ {isLoading ? ( + + ) : view === 'pricing' && venue ? ( + setView('main')} /> + ) : view === 'history' ? ( + setView('main')} /> + ) : venue ? ( +
+ {/* Compact image strip */} + {images.length > 0 && } + + {/* Quick detail chips */} +
+ } label={venue.venueType} /> + {venue.city && ( + } + label={`${venue.city}, ${venue.district}`} + /> + )} + {venue.maxCapacity && ( + } + label={`Up to ${venue.maxCapacity} guests`} + /> + )} + {venue.bookingType && ( + } + label={ + venue.bookingType === 'fixedBooking' ? 'Fixed Packages' : 'Flexible Slots' + } + onClick={() => setView('pricing')} + /> + )} +
+ + {/* Description */} + {venue.description && ( +
+

+ About +

+

+ {venue.description} +

+
+ )} + + {/* Address */} + {venue.address && ( +
+ + + {venue.address}, {venue.pincode} + +
+ )} + + {/* Amenities */} + {venue.amenities && venue.amenities.length > 0 && ( +
+

+ Amenities +

+
+ {venue.amenities.slice(0, 10).map((a, i) => ( + + {a} + + ))} + {venue.amenities.length > 10 && ( + + +{venue.amenities.length - 10} more + + )} +
+
+ )} + + {/* Space attributes */} + {venue.spaceAttributes && venue.spaceAttributes.length > 0 && ( +
+

+ Space Type +

+
+ {venue.spaceAttributes.map((a, i) => ( + + {a} + + ))} +
+
+ )} + + {myVenue.rejectionHistory && myVenue.rejectionHistory.length > 0 && ( +
setView('history')} + className="flex items-center justify-between cursor-pointer pt-3 border-t border-[var(--bg-grey)] group" + > +

+ Rejection Histories ({myVenue.rejectionHistory.length}/10) +

+ +
+ )} +
+ ) : ( +
+

Could not load venue details.

+

+ {myVenue.venueType} · {myVenue.city} +

+
+ )} +
+ + {/* Sticky Footer */} +
+

+ Private owner preview · not publicly visible +

+
+ + +
+
+
+
+ ); +}; + +export default VenuePreviewModal; diff --git a/client/src/pages/listVenue/myVenue/index.tsx b/client/src/pages/listVenue/myVenue/index.tsx new file mode 100644 index 0000000000..9171b568c9 --- /dev/null +++ b/client/src/pages/listVenue/myVenue/index.tsx @@ -0,0 +1,124 @@ +import { Link } from 'react-router'; +import { MdAddBusiness } from 'react-icons/md'; +import { useApiQuery } from '@/hooks/useApi'; +import { API_ENDPOINTS } from '@/constants'; +import type { MyVenue } from '@/types/venue.types'; +import VenueCard from './components/VenueCard'; +import { Button } from '@/components/ui/button'; +import VenueCardSkeleton from '@/components/common/VenueCardSkeleton'; + +interface MyVenuesResponse { + count: number; + venues: MyVenue[]; +} + +const MyVenues = () => { + const { data, isLoading, isError, refetch } = useApiQuery('my-venues', { + url: API_ENDPOINTS.MY_VENUES, + method: 'GET', + }); + + return ( +
+
+
+

My Venues

+

+ Manage your listed venues and check their approval status. +

+
+ + {data?.venues && data.venues.length > 0 && ( + + )} +
+ + {isLoading && } + + {isError && ( +
+

+ Failed to load your venues. Please try again. +

+ +
+ )} + + {data && data.venues.length === 0 && ( +
+
+ +
+

No venues yet

+

+ You haven't listed any venues yet. Start earning by listing your space for events, + weddings, and parties. +

+ +
+ )} + + {data && data.venues.length > 0 && ( +
+ {(() => { + const flagged = data.venues.filter((v) => v.status !== 'Approved'); + const approved = data.venues.filter((v) => v.status === 'Approved'); + return ( + <> + {flagged.length > 0 && ( +
+
+

+ Needs Attention +

+ + {flagged.length} + +
+
+ {flagged.map((venue) => ( + + ))} +
+
+ )} + {approved.length > 0 && ( +
+
+

+ Active Venues +

+ + {approved.length} + +
+
+ {approved.map((venue) => ( + + ))} +
+
+ )} + + ); + })()} +
+ )} +
+ ); +}; + +export default MyVenues; diff --git a/client/src/router.tsx b/client/src/router.tsx new file mode 100644 index 0000000000..a13e4594a9 --- /dev/null +++ b/client/src/router.tsx @@ -0,0 +1,68 @@ +import { BrowserRouter, Routes, Route } from 'react-router'; +import { AdminRedirect } from './components/common/AdminRedirect'; + +import HomePage from './pages/home'; +import ExplorePage from './pages/Explore'; +import RegisterPage from './pages/Auth/register'; +import LoginPage from './pages/Auth/login'; +import ForgotPasswordPage from './pages/Auth/forgotPassword'; +import ResetPasswordPage from './pages/Auth/resetPassword'; +import MainLayout from './layout/MainLayout'; +import ListVenueLayout from './pages/listVenue/ListVenueLayout'; +import MyVenues from './pages/listVenue/myVenue'; +import AddVenue from './pages/listVenue/addVenue'; +import AuthGuard from './components/common/AuthGuard'; +import GuestGuard from './components/common/GuestGuard'; +import VenueDetails from './pages/Explore/venueDetails'; +import WishlistPage from './pages/Wishlist'; +import BookingSummary from './pages/Booking/summary'; +import BookingConfirmation from './pages/Booking/confirmation'; +import MyBookingsPage from './pages/Booking/myBookings'; +import BookingDetailsPage from './pages/Booking/bookingDetails'; +import ProfilePage from './pages/Profile'; +import NotFound from './pages/NotFound'; + +export function AppRouter() { + return ( + + + {/* Public Layout Routes */} + }> + } /> + } /> + } /> + + + {/* Guest-only Routes */} + }> + } /> + } /> + } /> + } /> + + + {/* Protected Routes */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + + + + } /> + } /> + + + ); +} diff --git a/client/src/services/authService.ts b/client/src/services/authService.ts new file mode 100644 index 0000000000..22b19a80cf --- /dev/null +++ b/client/src/services/authService.ts @@ -0,0 +1,29 @@ +import { axiosInstance } from '@/config/axios'; +import { API_ENDPOINTS } from '@/constants'; + +export interface SessionSummary { + id: string; + ipAddress: string; + userAgent: string; + lastLogin: string; + createdAt: string; + isCurrent: boolean; +} + +export async function changePassword(oldPassword: string, newPassword: string): Promise { + await axiosInstance.patch(API_ENDPOINTS.CHANGE_PASSWORD, { oldPassword, newPassword }); +} + +export async function getSessions(): Promise { + const res = await axiosInstance.get(API_ENDPOINTS.SESSIONS); + return res.data?.data ?? res.data; +} + +export async function revokeSession(sessionId: string): Promise { + await axiosInstance.delete(API_ENDPOINTS.SESSION_BY_ID(sessionId)); +} + +export async function logoutOtherSessions(): Promise<{ revokedCount: number }> { + const res = await axiosInstance.post(API_ENDPOINTS.LOGOUT_OTHER_SESSIONS); + return res.data?.data ?? res.data; +} diff --git a/client/src/services/bookingService.ts b/client/src/services/bookingService.ts new file mode 100644 index 0000000000..82d81363fb --- /dev/null +++ b/client/src/services/bookingService.ts @@ -0,0 +1,50 @@ +import { axiosInstance as api } from '@/config/axios'; +import { API_ENDPOINTS } from '@/constants'; +import type { + BookingCardDTO, + BookingDetailDTO, + BookerInfoForm, + CancelBookingResponse, +} from '@/types/booking.types'; + +export type ApiResponse = { success: boolean; data: T; message?: string }; + +export const getMyBookings = async (): Promise< + ApiResponse<{ + bookings: { + upcoming: BookingCardDTO[]; + completed: BookingCardDTO[]; + cancelled: BookingCardDTO[]; + }; + }> +> => { + const response = await api.get(API_ENDPOINTS.MY_BOOKINGS); + return response.data; +}; + +export const getBookingById = async (id: string): Promise> => { + const response = await api.get(API_ENDPOINTS.BOOKING_BY_ID(id)); + return response.data; +}; + +export const saveBookerDetails = async ( + payload: BookerInfoForm & { lockId: string } +): Promise> => { + const { lockId, ...rest } = payload; + const { guestCount, eventType, ...bookerInfo } = rest; + const response = await api.patch(API_ENDPOINTS.SAVE_BOOKER_DETAILS, { + lockId, + guestCount: Number(guestCount), + eventType, + bookerInfo, + }); + return response.data; +}; + +export const cancelBooking = async ( + id: string, + reason?: string +): Promise> => { + const response = await api.delete(API_ENDPOINTS.CANCEL_BOOKING(id), { data: { reason } }); + return response.data; +}; diff --git a/client/src/services/reviewService.ts b/client/src/services/reviewService.ts new file mode 100644 index 0000000000..c127709898 --- /dev/null +++ b/client/src/services/reviewService.ts @@ -0,0 +1,95 @@ +import { axiosInstance } from '@/config/axios'; +import { API_ENDPOINTS } from '@/constants'; + +export interface ReviewDTO { + rating?: number; + comment?: string; +} + +export interface UpdateReviewDTO { + rating?: number; + comment?: string; +} + +export interface Review { + _id: string; + venueId: string; + user: { + id: string; + userName: string; + isVerified?: boolean; + }; + rating?: number; + comment?: string; + createdAt: string; + editedAt?: string; + reviewerRating?: number; + ownerReply?: { + text: string; + repliedAt: string; + }; +} + +export interface ReviewsResponse { + reviews: Review[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }; +} + +export async function getVenueReviews( + venueId: string, + page: number = 1, + limit: number = 10 +): Promise { + const { data } = await axiosInstance.get( + `${API_ENDPOINTS.VENUE_REVIEWS(venueId)}?page=${page}&limit=${limit}` + ); + + interface ReviewRaw { + userId?: { _id?: string; id?: string; username?: string; userName?: string } | string; + userName?: string; + isVerified?: boolean; + [key: string]: unknown; + } + + const reviews = data.data.reviews.map((r: ReviewRaw) => { + const userIdObj = typeof r.userId === 'object' && r.userId !== null ? r.userId : null; + return { + ...r, + user: { + id: userIdObj?._id || userIdObj?.id || r.userId, + userName: userIdObj?.username || userIdObj?.userName || r.userName || 'Unknown', + isVerified: r.isVerified, + }, + }; + }); + + return { + ...data.data, + reviews, + }; +} + +export async function submitReview(venueId: string, dto: ReviewDTO): Promise { + const { data } = await axiosInstance.post(API_ENDPOINTS.VENUE_REVIEWS(venueId), dto); + return data.data; +} + +export async function getMyRating(venueId: string): Promise { + const { data } = await axiosInstance.get(API_ENDPOINTS.VENUE_MY_RATING(venueId)); + return data.data.rating; +} + +export async function updateReview(reviewId: string, dto: UpdateReviewDTO): Promise { + const { data } = await axiosInstance.patch(API_ENDPOINTS.REVIEW_BY_ID(reviewId), dto); + return data.data; +} + +export async function deleteReview(reviewId: string): Promise { + await axiosInstance.delete(API_ENDPOINTS.REVIEW_BY_ID(reviewId)); +} diff --git a/client/src/services/userService.ts b/client/src/services/userService.ts new file mode 100644 index 0000000000..a6ac862fe6 --- /dev/null +++ b/client/src/services/userService.ts @@ -0,0 +1,38 @@ +import { axiosInstance } from '@/config/axios'; +import { API_ENDPOINTS } from '@/constants'; + +export interface UpdateProfileDto { + username?: string; + profilePicturePublicId?: string; +} + +export interface CloudinarySignatureResponse { + signature: string; + timestamp: number; + cloudName: string; + apiKey: string; + uploadPreset: string; + folder: string; +} + +export interface UpdatedProfile { + _id: string; + name: string; + email: string; + profilePicture?: string; +} + +export async function updateProfile(dto: UpdateProfileDto): Promise { + const res = await axiosInstance.patch(API_ENDPOINTS.PROFILE, dto); + return res.data?.data ?? res.data; +} + +export async function getAvatarUploadSignature(): Promise { + const res = await axiosInstance.get(API_ENDPOINTS.PROFILE_UPLOAD_SIGNATURE); + return res.data?.data ?? res.data; +} + +export async function deleteProfilePicture(): Promise { + const res = await axiosInstance.delete(API_ENDPOINTS.PROFILE_PICTURE); + return res.data?.data ?? res.data; +} diff --git a/client/src/services/venueService.ts b/client/src/services/venueService.ts new file mode 100644 index 0000000000..62094b4e09 --- /dev/null +++ b/client/src/services/venueService.ts @@ -0,0 +1,54 @@ +import { axiosInstance } from '@/config/axios'; +import { API_ENDPOINTS } from '@/constants'; +import type { VenueFilters } from '@/types/venue.types'; + +export async function createVenue(dto: unknown) { + const res = await axiosInstance.post(API_ENDPOINTS.VENUES, dto); + return res.data?.data ?? res.data; +} + +export async function submitVenue(venueId: string) { + const res = await axiosInstance.post(API_ENDPOINTS.VENUE_SUBMIT(venueId)); + return res.data?.data ?? res.data; +} + +export async function getUploadSignature() { + const res = await axiosInstance.get(API_ENDPOINTS.UPLOAD_SIGNATURE); + return res.data?.data ?? res.data; +} + +export async function getMyVenues() { + const res = await axiosInstance.get(API_ENDPOINTS.MY_VENUES); + return res.data?.data ?? res.data; +} + +// PUT /venues/draft +export async function upsertVenueDraft(step: number, formValues: unknown) { + const res = await axiosInstance.put(API_ENDPOINTS.VENUE_DRAFT, { step, formValues }); + return res.data?.data ?? res.data; +} + +// GET /venues/draft +export async function getMyDraft(): Promise | null> { + const res = await axiosInstance.get(API_ENDPOINTS.VENUE_DRAFT); + return res.data?.data ?? null; +} + +// PUT /venues/:id +export async function updateVenue(venueId: string, dto: unknown) { + const res = await axiosInstance.put(API_ENDPOINTS.VENUE_UPDATE(venueId), dto); + return res.data?.data ?? res.data; +} +// GET /venues +export async function getPublicVenues(filters: VenueFilters, page: number, limit: number) { + const res = await axiosInstance.get(API_ENDPOINTS.VENUES, { + params: { ...filters, page, limit }, + }); + return res.data?.data ?? res.data; +} + +// GET /venues/:id +export async function getVenueById(id: string) { + const res = await axiosInstance.get(API_ENDPOINTS.VENUE_BY_ID(id)); + return res.data?.data ?? res.data; +} diff --git a/client/src/services/wishlistService.ts b/client/src/services/wishlistService.ts new file mode 100644 index 0000000000..3baac0a1fb --- /dev/null +++ b/client/src/services/wishlistService.ts @@ -0,0 +1,50 @@ +import { axiosInstance } from '@/config/axios'; +import { API_ENDPOINTS } from '@/constants'; + +export interface WishlistResponse { + wishlisted: boolean; +} + +export interface WishlistStatusResponse { + [venueId: string]: boolean; +} + +export async function toggleWishlist(venueId: string): Promise { + const { data } = await axiosInstance.post<{ data: WishlistResponse }>( + API_ENDPOINTS.WISHLIST_TOGGLE(venueId) + ); + return data.data; +} + +export async function getMyWishlist(page: number = 1, limit: number = 20) { + const { data } = await axiosInstance.get(API_ENDPOINTS.WISHLIST, { + params: { page, limit }, + }); + return data; +} + +export async function getWishlistStatus(venueIds: string[]): Promise { + if (!venueIds.length) return {}; + + const { data } = await axiosInstance.get<{ data: WishlistStatusResponse }>( + API_ENDPOINTS.WISHLIST_STATUS, + { + params: { + venueIds: venueIds.join(','), + }, + } + ); + return data.data; +} + +export async function syncWishlist(venueIds: string[]): Promise { + if (!venueIds.length) return {}; + + const { data } = await axiosInstance.post<{ data: WishlistStatusResponse }>( + API_ENDPOINTS.WISHLIST_SYNC, + { + venueIds, + } + ); + return data.data; +} diff --git a/client/src/tests/config/axios.test.ts b/client/src/tests/config/axios.test.ts new file mode 100644 index 0000000000..a53c5da860 --- /dev/null +++ b/client/src/tests/config/axios.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/constants', () => ({ + API_BASE_URL: 'http://test.local', + STORAGE_KEYS: { + SESSION_TOKEN: 'x-session-token', + IS_LOGGED_IN: 'isLoggedIn', + USER_ID: 'user_id', + USER_NAME: 'user_name', + USER_ROLE: 'user_role', + }, + API_ENDPOINTS: { + LOGIN: '/auth/login', + REGISTER: '/auth/register', + REFRESH: '/auth/refresh', + LOGOUT: '/auth/logout', + PROFILE: '/user/profile', + VENUES: '/venues', + MY_VENUES: '/venues/my-venues', + VENUE_BY_ID: (id: string) => `/venues/${id}`, + VENUE_SUBMIT: (id: string) => `/venues/${id}/submit`, + VENUE_UPDATE: (id: string) => `/venues/${id}`, + VENUE_DRAFT: '/venues/draft', + UPLOAD_SIGNATURE: '/venues/upload-signature', + MY_BOOKINGS: '/bookings/my-bookings', + BOOKING_BY_ID: (id: string) => `/bookings/${id}`, + SAVE_BOOKER_DETAILS: '/bookings/booker-details', + CANCEL_BOOKING: (id: string) => `/bookings/${id}`, + CHECKOUT: '/bookings/checkout', + RELEASE_LOCK: '/availability/lock', + GET_AVAILABILITY: (id: string) => `/availability/${id}`, + BLOCK_SLOT: (id: string) => `/availability/${id}/block`, + FORGOT_PASSWORD: '/auth/forgot-password', + RESET_PASSWORD: '/auth/reset-password', + SEARCH: '/search', + DASHBOARD: '/dashboard', + }, +})); + +describe('createAxiosInstance', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('should create an axios instance with correct defaults', async () => { + const { createAxiosInstance } = await import('../../config/axios'); + const instance = createAxiosInstance(); + expect(instance.defaults.baseURL).toBe('http://test.local/api/v1'); + expect(instance.defaults.withCredentials).toBe(true); + expect(instance.defaults.timeout).toBe(30000); + }); + + it('should set session token in request interceptor', async () => { + localStorage.setItem('x-session-token', 'test-token-123'); + const { createAxiosInstance } = await import('../../config/axios'); + const instance = createAxiosInstance(); + const interceptor = instance.interceptors.request; + expect(interceptor).toBeDefined(); + }); + + it('should generate session token on request', async () => { + const { createAxiosInstance } = await import('../../config/axios'); + const instance = createAxiosInstance(); + instance.interceptors.request.handlers[0].fulfilled({ headers: {} } as never); + const token = localStorage.getItem('x-session-token'); + expect(token).toBeTruthy(); + expect(token?.length).toBeGreaterThan(0); + }); + + it('should export a singleton axiosInstance', async () => { + const { axiosInstance } = await import('../../config/axios'); + expect(axiosInstance).toBeDefined(); + expect(axiosInstance.defaults.baseURL).toBe('http://test.local/api/v1'); + }); + + it('should handle error extraction from AxiosError message', () => { + const error = new Error('Request failed'); + expect(error.message).toBe('Request failed'); + }); + + it('should handle errors without response', () => { + const error = new Error('Network Error'); + expect(error.message).toBe('Network Error'); + }); +}); diff --git a/client/src/tests/config/queryClient.test.ts b/client/src/tests/config/queryClient.test.ts new file mode 100644 index 0000000000..fd330c9104 --- /dev/null +++ b/client/src/tests/config/queryClient.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; + +describe('queryClient', () => { + it('should export a QueryClient with correct defaults', async () => { + const { queryClient } = await import('../../config/queryClient'); + expect(queryClient).toBeDefined(); + expect(queryClient.getQueryDefaults).toBeDefined(); + expect(queryClient.getMutationDefaults).toBeDefined(); + }); + + it('should have default staleTime of 5 minutes', async () => { + const { queryClient } = await import('../../config/queryClient'); + const defaults = queryClient.getQueryDefaults(); + expect(defaults).toBeDefined(); + }); +}); diff --git a/client/src/tests/context/AuthContext.test.tsx b/client/src/tests/context/AuthContext.test.tsx new file mode 100644 index 0000000000..3037453e7e --- /dev/null +++ b/client/src/tests/context/AuthContext.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockClear = vi.fn(); + +vi.mock('@/config/axios', () => ({ + axiosInstance: { + get: mockGet, + post: mockPost, + }, +})); + +vi.mock('@/config/queryClient', () => ({ + queryClient: { + clear: mockClear, + }, +})); + +vi.mock('@/hooks/useToast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), +})); + +vi.mock('@/utils/venueDraft', () => ({ + clearDraft: vi.fn(), + clearDraftSession: vi.fn(), +})); + +vi.mock('@/utils/profileGreeting', () => ({ + resetProfileGreeting: vi.fn(), +})); + +vi.mock('@/constants', () => ({ + STORAGE_KEYS: { + SESSION_TOKEN: 'x-session-token', + IS_LOGGED_IN: 'isLoggedIn', + USER_ID: 'user_id', + USER_NAME: 'user_name', + USER_ROLE: 'user_role', + }, + API_ENDPOINTS: { + LOGIN: '/auth/login', + REGISTER: '/auth/register', + LOGOUT: '/auth/logout', + PROFILE: '/user/profile', + }, +})); + +describe('AuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('should render children', async () => { + const { AuthProvider } = await import('../../context/AuthContext'); + render( + +
child content
+
+ ); + expect(screen.getByText('child content')).toBeInTheDocument(); + }); + + it('should call verifySession on mount when logged in', async () => { + localStorage.setItem('isLoggedIn', 'true'); + mockGet.mockResolvedValue({ + data: { data: { _id: 'user-1', username: 'john', email: 'john@test.com' } }, + }); + + const { AuthProvider } = await import('../../context/AuthContext'); + render( + +
child
+
+ ); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalledWith('/user/profile'); + }); + }); + + it('should not call verifySession on mount when not logged in', async () => { + const { AuthProvider } = await import('../../context/AuthContext'); + render( + +
child
+
+ ); + + await waitFor(() => { + expect(mockGet).not.toHaveBeenCalled(); + }); + }); + + it('should clear auth data on auth:logout event', async () => { + const { AuthProvider } = await import('../../context/AuthContext'); + render( + +
child
+
+ ); + + act(() => { + window.dispatchEvent(new CustomEvent('auth:logout')); + }); + + await waitFor(() => { + expect(mockClear).toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/tests/context/ThemeContext.test.tsx b/client/src/tests/context/ThemeContext.test.tsx new file mode 100644 index 0000000000..c8a26cdce4 --- /dev/null +++ b/client/src/tests/context/ThemeContext.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '@/context/ThemeContext'; +import { useTheme } from '@/hooks/useTheme'; +import { STORAGE_KEYS } from '@/constants'; + +const TestComponent: React.FC = () => { + const { themePreference, resolvedTheme, setThemePreference } = useTheme(); + + return ( +
+ {themePreference} + {resolvedTheme} + + +
+ ); +}; + +describe('client ThemeContext & ThemeProvider', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove('dark'); + }); + + it('should render default light theme when no preference stored', () => { + render( + + + + ); + + expect(screen.getByTestId('pref')).toHaveTextContent('light'); + expect(screen.getByTestId('resolved')).toHaveTextContent('light'); + }); + + it('should update theme preference and add "dark" class to document element', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByText('Set Dark')); + + expect(screen.getByTestId('pref')).toHaveTextContent('dark'); + expect(screen.getByTestId('resolved')).toHaveTextContent('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + expect(localStorage.getItem(STORAGE_KEYS.THEME)).toBe('dark'); + }); + + it('should throw error when useTheme is used outside of ThemeProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => render()).toThrow('useTheme must be used within a ThemeProvider'); + + consoleSpy.mockRestore(); + }); +}); diff --git a/client/src/tests/hooks/useApi.test.tsx b/client/src/tests/hooks/useApi.test.tsx new file mode 100644 index 0000000000..0d4f0686d7 --- /dev/null +++ b/client/src/tests/hooks/useApi.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +const mockRequest = vi.fn(); +const mockAxiosInstance = Object.assign( + function (config: Record) { + return mockRequest(config); + }, + { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + request: mockRequest, + defaults: {} as Record, + interceptors: { + request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, + response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, + }, + } +); + +vi.mock('@/config/axios', () => ({ + axiosInstance: mockAxiosInstance, +})); + +vi.mock('@/hooks/useToast', () => ({ + useToast: () => ({ error: vi.fn(), success: vi.fn(), info: vi.fn() }), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useApiQuery', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch data and unwrap response.data.data', async () => { + mockRequest.mockResolvedValue({ + data: { data: { id: 1, name: 'Test' } }, + }); + + const { useApiQuery } = await import('../../hooks/useApi'); + + const { result } = renderHook( + () => useApiQuery<{ id: number; name: string }>(['test'], { method: 'GET', url: '/test' }), + { wrapper: createWrapper() } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ id: 1, name: 'Test' }); + expect(mockRequest).toHaveBeenCalledWith({ method: 'GET', url: '/test' }); + }); + + it('should fall back to response.data when data.data is undefined', async () => { + mockRequest.mockResolvedValue({ + data: { message: 'ok' }, + }); + + const { useApiQuery } = await import('../../hooks/useApi'); + + const { result } = renderHook(() => useApiQuery(['test'], { method: 'GET', url: '/test' }), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ message: 'ok' }); + }); + + it('should pass query options through', async () => { + mockRequest.mockResolvedValue({ + data: { data: 'value' }, + }); + + const { useApiQuery } = await import('../../hooks/useApi'); + const staleTime = 10000; + const enabled = false; + + const { result } = renderHook( + () => useApiQuery(['test'], { method: 'GET', url: '/test' }, { staleTime, enabled }), + { wrapper: createWrapper() } + ); + + expect(result.current.isFetching).toBe(false); + }); + + it('should accept string query key', async () => { + mockRequest.mockResolvedValue({ + data: { data: 'result' }, + }); + + const { useApiQuery } = await import('../../hooks/useApi'); + + const { result } = renderHook(() => useApiQuery('test-key', { method: 'GET', url: '/test' }), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe('result'); + }); +}); + +describe('useApiMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call mutation with config and merge variables as data', async () => { + mockRequest.mockResolvedValue({ + data: { data: { id: 1 } }, + }); + + const { useApiMutation } = await import('../../hooks/useApi'); + + const { result } = renderHook(() => useApiMutation({ method: 'POST', url: '/create' }), { + wrapper: createWrapper(), + }); + + result.current.mutate({ name: 'New Item' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockRequest).toHaveBeenCalledWith({ + method: 'POST', + url: '/create', + data: { name: 'New Item' }, + }); + expect(result.current.data).toEqual({ id: 1 }); + }); + + it('should fail on network error', async () => { + mockRequest.mockRejectedValue(new Error('Network Error')); + + const { useApiMutation } = await import('../../hooks/useApi'); + + const { result } = renderHook(() => useApiMutation({ method: 'POST', url: '/fail' }), { + wrapper: createWrapper(), + }); + + result.current.mutate({}); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); + }); +}); diff --git a/client/src/tests/hooks/useAuth.test.tsx b/client/src/tests/hooks/useAuth.test.tsx new file mode 100644 index 0000000000..cc92c111f8 --- /dev/null +++ b/client/src/tests/hooks/useAuth.test.tsx @@ -0,0 +1,18 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +vi.mock('@/context/AuthContext', () => ({ + AuthContext: { + Consumer: ({ children }: { children: (value: unknown) => unknown }) => children({}), + }, +})); + +describe('useAuth', () => { + it('should throw when used outside AuthProvider', async () => { + const { useAuth } = await import('../../hooks/useAuth'); + + expect(() => { + renderHook(() => useAuth()); + }).toThrow('useAuth must be used within an AuthProvider'); + }); +}); diff --git a/client/src/tests/hooks/useDebounce.test.ts b/client/src/tests/hooks/useDebounce.test.ts new file mode 100644 index 0000000000..fdff238d52 --- /dev/null +++ b/client/src/tests/hooks/useDebounce.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return initial value immediately', async () => { + const { useDebounce } = await import('../../hooks/useDebounce'); + const { result } = renderHook(() => useDebounce('hello', 500)); + expect(result.current).toBe('hello'); + }); + + it('should update value after delay', async () => { + const { useDebounce } = await import('../../hooks/useDebounce'); + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: 'hello', delay: 500 }, + }); + + rerender({ value: 'world', delay: 500 }); + + expect(result.current).toBe('hello'); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current).toBe('world'); + }); + + it('should reset timer when value changes', async () => { + const { useDebounce } = await import('../../hooks/useDebounce'); + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: 'a', delay: 500 }, + }); + + rerender({ value: 'ab', delay: 500 }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + rerender({ value: 'abc', delay: 500 }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current).toBe('a'); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(result.current).toBe('abc'); + }); + + it('should work with number values', async () => { + const { useDebounce } = await import('../../hooks/useDebounce'); + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: 0, delay: 300 }, + }); + + rerender({ value: 42, delay: 300 }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current).toBe(42); + }); +}); diff --git a/client/src/tests/hooks/useLockTimer.test.ts b/client/src/tests/hooks/useLockTimer.test.ts new file mode 100644 index 0000000000..7867bfef35 --- /dev/null +++ b/client/src/tests/hooks/useLockTimer.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +describe('useLockTimer', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-25T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return 0 remaining when no expiresAt', async () => { + const { useLockTimer } = await import('../../hooks/useLockTimer'); + const { result } = renderHook(() => useLockTimer(null)); + expect(result.current.remainingMs).toBe(0); + expect(result.current.formattedTime).toBe('00:00'); + expect(result.current.isExpired).toBe(false); + }); + + it('should show remaining time in MM:SS', async () => { + const { useLockTimer } = await import('../../hooks/useLockTimer'); + const future = new Date('2026-07-25T12:05:00Z'); + const { result } = renderHook(() => useLockTimer(future)); + + expect(result.current.remainingMs).toBe(5 * 60 * 1000); + expect(result.current.formattedTime).toBe('05:00'); + expect(result.current.isExpired).toBe(false); + }); + + it('should count down every second', async () => { + const { useLockTimer } = await import('../../hooks/useLockTimer'); + const future = new Date('2026-07-25T12:02:00Z'); + const { result } = renderHook(() => useLockTimer(future)); + + expect(result.current.formattedTime).toBe('02:00'); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.formattedTime).toBe('01:59'); + }); + + it('should mark as expired when time runs out', async () => { + const { useLockTimer } = await import('../../hooks/useLockTimer'); + const future = new Date('2026-07-25T12:00:01Z'); + const onExpire = vi.fn(); + const { result } = renderHook(() => useLockTimer(future, onExpire)); + + expect(result.current.isExpired).toBe(false); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.isExpired).toBe(true); + expect(result.current.formattedTime).toBe('00:00'); + expect(onExpire).toHaveBeenCalledTimes(1); + }); + + it('should trigger onExpire exactly once when transitioning to expired', async () => { + const { useLockTimer } = await import('../../hooks/useLockTimer'); + const future = new Date('2026-07-25T12:00:01Z'); + const onExpire = vi.fn(); + const { rerender } = renderHook(({ expiresAt, onExpire: oe }) => useLockTimer(expiresAt, oe), { + initialProps: { expiresAt: future, onExpire }, + }); + + expect(onExpire).toHaveBeenCalledTimes(0); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(onExpire).toHaveBeenCalledTimes(1); + + rerender({ expiresAt: new Date('2026-07-25T12:00:01Z'), onExpire }); + + expect(onExpire).toHaveBeenCalledTimes(1); + }); + + it('should round up to nearest second', async () => { + const { useLockTimer } = await import('../../hooks/useLockTimer'); + const future = new Date('2026-07-25T12:00:00.500Z'); + const { result } = renderHook(() => useLockTimer(future)); + expect(result.current.formattedTime).toBe('00:01'); + }); +}); diff --git a/client/src/tests/pages/Auth/login/validation.test.ts b/client/src/tests/pages/Auth/login/validation.test.ts new file mode 100644 index 0000000000..6a18b86ede --- /dev/null +++ b/client/src/tests/pages/Auth/login/validation.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; + +describe('signinSchema', () => { + it('should validate a valid login', async () => { + const { signinSchema } = await import('../../../../pages/Auth/login/validation'); + const valid = await signinSchema.isValid({ + email: 'user@test.com', + password: 'mypassword', + }); + expect(valid).toBe(true); + }); + + it('should reject missing email', async () => { + const { signinSchema } = await import('../../../../pages/Auth/login/validation'); + await expect(signinSchema.validate({ password: 'mypassword' })).rejects.toThrow( + 'Email is required' + ); + }); + + it('should reject invalid email format', async () => { + const { signinSchema } = await import('../../../../pages/Auth/login/validation'); + await expect( + signinSchema.validate({ email: 'notanemail', password: 'mypassword' }) + ).rejects.toThrow('Enter a valid email address'); + }); + + it('should reject missing password', async () => { + const { signinSchema } = await import('../../../../pages/Auth/login/validation'); + await expect(signinSchema.validate({ email: 'user@test.com' })).rejects.toThrow( + 'Password required' + ); + }); +}); diff --git a/client/src/tests/pages/Auth/register/validation.test.ts b/client/src/tests/pages/Auth/register/validation.test.ts new file mode 100644 index 0000000000..d79d9626d9 --- /dev/null +++ b/client/src/tests/pages/Auth/register/validation.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; + +describe('signupSchema', () => { + it('should validate a valid signup', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + const valid = await signupSchema.isValid({ + name: 'John Doe', + email: 'user@test.com', + password: 'Passw0rd!', + confirmPassword: 'Passw0rd!', + }); + expect(valid).toBe(true); + }); + + it('should reject missing name', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + await expect( + signupSchema.validate({ + email: 'user@test.com', + password: 'Passw0rd!', + confirmPassword: 'Passw0rd!', + }) + ).rejects.toThrow('Full name is required'); + }); + + it('should reject missing email', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + await expect( + signupSchema.validate({ + name: 'John', + password: 'Passw0rd!', + confirmPassword: 'Passw0rd!', + }) + ).rejects.toThrow('Email is required'); + }); + + it('should reject invalid email', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + await expect( + signupSchema.validate({ + name: 'John', + email: 'bad', + password: 'Passw0rd!', + confirmPassword: 'Passw0rd!', + }) + ).rejects.toThrow('Enter a valid email address'); + }); + + it('should reject password shorter than 8 characters', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + await expect( + signupSchema.validate({ + name: 'John', + email: 'user@test.com', + password: 'Short1!', + confirmPassword: 'Short1!', + }) + ).rejects.toThrow('at least 8 characters'); + }); + + it('should reject password missing lowercase', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + await expect( + signupSchema.validate({ + name: 'John', + email: 'user@test.com', + password: 'UPPERCASE1!', + confirmPassword: 'UPPERCASE1!', + }) + ).rejects.toThrow('lowercase letter'); + }); + + it('should reject password missing uppercase', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + await expect( + signupSchema.validate({ + name: 'John', + email: 'user@test.com', + password: 'lowercase1!', + confirmPassword: 'lowercase1!', + }) + ).rejects.toThrow('uppercase letter'); + }); + + it('should reject password missing number', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + await expect( + signupSchema.validate({ + name: 'John', + email: 'user@test.com', + password: 'NoNumber!', + confirmPassword: 'NoNumber!', + }) + ).rejects.toThrow('at least one number'); + }); + + it('should reject password missing special symbol', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + await expect( + signupSchema.validate({ + name: 'John', + email: 'user@test.com', + password: 'NoSymbol1', + confirmPassword: 'NoSymbol1', + }) + ).rejects.toThrow('special symbol'); + }); + + it('should reject mismatched passwords', async () => { + const { signupSchema } = await import('../../../../pages/Auth/register/validation'); + await expect( + signupSchema.validate({ + name: 'John', + email: 'user@test.com', + password: 'Passw0rd!', + confirmPassword: 'Different1!', + }) + ).rejects.toThrow('Passwords do not match'); + }); +}); diff --git a/client/src/tests/services/authService.test.ts b/client/src/tests/services/authService.test.ts new file mode 100644 index 0000000000..65ce73033c --- /dev/null +++ b/client/src/tests/services/authService.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + changePassword, + getSessions, + revokeSession, + logoutOtherSessions, +} from '@/services/authService'; +import { axiosInstance } from '@/config/axios'; +import { API_ENDPOINTS } from '@/constants'; + +vi.mock('@/config/axios', () => ({ + axiosInstance: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('client authService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call changePassword endpoint with old and new password', async () => { + vi.mocked(axiosInstance.patch).mockResolvedValueOnce({ data: { success: true } }); + + await changePassword('OldPass123!', 'NewPass123!'); + + expect(axiosInstance.patch).toHaveBeenCalledWith(API_ENDPOINTS.CHANGE_PASSWORD, { + oldPassword: 'OldPass123!', + newPassword: 'NewPass123!', + }); + }); + + it('should fetch user sessions list via getSessions', async () => { + const mockSessions = [ + { + id: 'sess_1', + ipAddress: '127.0.0.1', + userAgent: 'Chrome', + lastLogin: '2026-07-22', + createdAt: '2026-07-22', + isCurrent: true, + }, + ]; + + vi.mocked(axiosInstance.get).mockResolvedValueOnce({ data: { data: mockSessions } }); + + const result = await getSessions(); + + expect(axiosInstance.get).toHaveBeenCalledWith(API_ENDPOINTS.SESSIONS); + expect(result).toEqual(mockSessions); + }); + + it('should revoke a session via revokeSession', async () => { + vi.mocked(axiosInstance.delete).mockResolvedValueOnce({ data: { success: true } }); + + await revokeSession('sess_123'); + + expect(axiosInstance.delete).toHaveBeenCalledWith(API_ENDPOINTS.SESSION_BY_ID('sess_123')); + }); + + it('should call logoutOtherSessions and return revoked count', async () => { + vi.mocked(axiosInstance.post).mockResolvedValueOnce({ data: { data: { revokedCount: 2 } } }); + + const result = await logoutOtherSessions(); + + expect(axiosInstance.post).toHaveBeenCalledWith(API_ENDPOINTS.LOGOUT_OTHER_SESSIONS); + expect(result).toEqual({ revokedCount: 2 }); + }); +}); diff --git a/client/src/tests/services/bookingService.test.ts b/client/src/tests/services/bookingService.test.ts new file mode 100644 index 0000000000..821bc845f6 --- /dev/null +++ b/client/src/tests/services/bookingService.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGet = vi.fn(); +const mockPatch = vi.fn(); +const mockDelete = vi.fn(); + +vi.mock('@/config/axios', () => ({ + axiosInstance: { + get: mockGet, + patch: mockPatch, + delete: mockDelete, + }, +})); + +vi.mock('@/constants', () => ({ + API_ENDPOINTS: { + MY_BOOKINGS: '/bookings/my-bookings', + BOOKING_BY_ID: (id: string) => `/bookings/${id}`, + SAVE_BOOKER_DETAILS: '/bookings/booker-details', + CANCEL_BOOKING: (id: string) => `/bookings/${id}`, + }, +})); + +describe('bookingService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getMyBookings', () => { + it('should GET my-bookings and return response data', async () => { + const responseData = { + success: true, + data: { + bookings: { + upcoming: [{ _id: 'b1' }], + completed: [], + cancelled: [], + }, + }, + }; + mockGet.mockResolvedValue({ data: responseData }); + + const { getMyBookings } = await import('../../services/bookingService'); + const result = await getMyBookings(); + + expect(mockGet).toHaveBeenCalledWith('/bookings/my-bookings'); + expect(result).toEqual(responseData); + }); + }); + + describe('getBookingById', () => { + it('should GET booking by id and return response data', async () => { + const responseData = { + success: true, + data: { + _id: 'b1', + venueName: 'Grand Hall', + bookingRef: 'REF-001', + }, + }; + mockGet.mockResolvedValue({ data: responseData }); + + const { getBookingById } = await import('../../services/bookingService'); + const result = await getBookingById('b1'); + + expect(mockGet).toHaveBeenCalledWith('/bookings/b1'); + expect(result).toEqual(responseData); + }); + }); + + describe('saveBookerDetails', () => { + it('should PATCH booker details with lockId', async () => { + mockPatch.mockResolvedValue({ data: { success: true, data: null } }); + + const { saveBookerDetails } = await import('../../services/bookingService'); + const result = await saveBookerDetails({ + lockId: 'lock-1', + guestCount: 50, + eventType: 'wedding', + name: 'John', + email: 'john@test.com', + phone: '9876543210', + place: 'Kochi', + }); + + expect(mockPatch).toHaveBeenCalledWith('/bookings/booker-details', { + lockId: 'lock-1', + guestCount: 50, + eventType: 'wedding', + bookerInfo: { + name: 'John', + email: 'john@test.com', + phone: '9876543210', + place: 'Kochi', + }, + }); + expect(result).toEqual({ success: true, data: null }); + }); + }); + + describe('cancelBooking', () => { + it('should DELETE booking with reason', async () => { + mockDelete.mockResolvedValue({ data: { success: true, data: { refundAmount: 500 } } }); + + const { cancelBooking } = await import('../../services/bookingService'); + const result = await cancelBooking('b1', 'Changed mind'); + + expect(mockDelete).toHaveBeenCalledWith('/bookings/b1', { data: { reason: 'Changed mind' } }); + expect(result).toEqual({ success: true, data: { refundAmount: 500 } }); + }); + + it('should cancel without reason', async () => { + mockDelete.mockResolvedValue({ data: { success: true, data: { refundAmount: 0 } } }); + + const { cancelBooking } = await import('../../services/bookingService'); + const result = await cancelBooking('b1'); + + expect(mockDelete).toHaveBeenCalledWith('/bookings/b1', { data: { reason: undefined } }); + expect(result).toEqual({ success: true, data: { refundAmount: 0 } }); + }); + }); +}); diff --git a/client/src/tests/services/venueService.test.ts b/client/src/tests/services/venueService.test.ts new file mode 100644 index 0000000000..a99f53f564 --- /dev/null +++ b/client/src/tests/services/venueService.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPut = vi.fn(); + +vi.mock('@/config/axios', () => ({ + axiosInstance: { + get: mockGet, + post: mockPost, + put: mockPut, + }, +})); + +vi.mock('@/constants', () => ({ + API_ENDPOINTS: { + VENUES: '/venues', + VENUE_SUBMIT: (id: string) => `/venues/${id}/submit`, + UPLOAD_SIGNATURE: '/venues/upload-signature', + MY_VENUES: '/venues/my-venues', + VENUE_DRAFT: '/venues/draft', + VENUE_UPDATE: (id: string) => `/venues/${id}`, + VENUE_BY_ID: (id: string) => `/venues/${id}`, + }, +})); + +describe('venueService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createVenue', () => { + it('should POST to VENUES endpoint', async () => { + mockPost.mockResolvedValue({ data: { data: { id: 'venue-1' } } }); + const { createVenue } = await import('../../services/venueService'); + const result = await createVenue({ name: 'Test' }); + expect(mockPost).toHaveBeenCalledWith('/venues', { name: 'Test' }); + expect(result).toEqual({ id: 'venue-1' }); + }); + + it('should fall back to response.data', async () => { + mockPost.mockResolvedValue({ data: { id: 'venue-1' } }); + const { createVenue } = await import('../../services/venueService'); + const result = await createVenue({ name: 'Test' }); + expect(result).toEqual({ id: 'venue-1' }); + }); + }); + + describe('submitVenue', () => { + it('should POST to VENUE_SUBMIT endpoint', async () => { + mockPost.mockResolvedValue({ data: { data: { status: 'PendingReview' } } }); + const { submitVenue } = await import('../../services/venueService'); + const result = await submitVenue('venue-1'); + expect(mockPost).toHaveBeenCalledWith('/venues/venue-1/submit'); + expect(result).toEqual({ status: 'PendingReview' }); + }); + }); + + describe('getUploadSignature', () => { + it('should GET upload-signature endpoint', async () => { + mockGet.mockResolvedValue({ data: { data: { signature: 'abc' } } }); + const { getUploadSignature } = await import('../../services/venueService'); + const result = await getUploadSignature(); + expect(mockGet).toHaveBeenCalledWith('/venues/upload-signature'); + expect(result).toEqual({ signature: 'abc' }); + }); + }); + + describe('getMyVenues', () => { + it('should GET my-venues endpoint', async () => { + mockGet.mockResolvedValue({ data: { data: [{ id: 'v1' }] } }); + const { getMyVenues } = await import('../../services/venueService'); + const result = await getMyVenues(); + expect(mockGet).toHaveBeenCalledWith('/venues/my-venues'); + expect(result).toEqual([{ id: 'v1' }]); + }); + }); + + describe('upsertVenueDraft', () => { + it('should PUT to draft endpoint with step and formValues', async () => { + mockPut.mockResolvedValue({ data: { data: { id: 'draft-1' } } }); + const { upsertVenueDraft } = await import('../../services/venueService'); + const formValues = { VenueName: 'Test' }; + const result = await upsertVenueDraft(1, formValues); + expect(mockPut).toHaveBeenCalledWith('/venues/draft', { step: 1, formValues }); + expect(result).toEqual({ id: 'draft-1' }); + }); + }); + + describe('getMyDraft', () => { + it('should GET draft endpoint and return data', async () => { + mockGet.mockResolvedValue({ data: { data: { step: 1 } } }); + const { getMyDraft } = await import('../../services/venueService'); + const result = await getMyDraft(); + expect(mockGet).toHaveBeenCalledWith('/venues/draft'); + expect(result).toEqual({ step: 1 }); + }); + + it('should return null when no draft', async () => { + mockGet.mockResolvedValue({ data: {} }); + const { getMyDraft } = await import('../../services/venueService'); + const result = await getMyDraft(); + expect(result).toBeNull(); + }); + }); + + describe('updateVenue', () => { + it('should PUT to venue update endpoint', async () => { + mockPut.mockResolvedValue({ data: { data: { id: 'venue-1' } } }); + const { updateVenue } = await import('../../services/venueService'); + const result = await updateVenue('venue-1', { name: 'Updated' }); + expect(mockPut).toHaveBeenCalledWith('/venues/venue-1', { name: 'Updated' }); + expect(result).toEqual({ id: 'venue-1' }); + }); + }); + + describe('getPublicVenues', () => { + it('should GET venues with filters, page, limit', async () => { + mockGet.mockResolvedValue({ data: { data: { venues: [{ id: 'v1' }], pagination: {} } } }); + const { getPublicVenues } = await import('../../services/venueService'); + const result = await getPublicVenues({ venueType: ['hall'] }, 1, 12); + expect(mockGet).toHaveBeenCalledWith('/venues', { + params: { venueType: ['hall'], page: 1, limit: 12 }, + }); + expect(result).toEqual({ venues: [{ id: 'v1' }], pagination: {} }); + }); + }); + + describe('getVenueById', () => { + it('should GET venue by id endpoint', async () => { + mockGet.mockResolvedValue({ data: { data: { id: 'venue-1', name: 'Grand Hall' } } }); + const { getVenueById } = await import('../../services/venueService'); + const result = await getVenueById('venue-1'); + expect(mockGet).toHaveBeenCalledWith('/venues/venue-1'); + expect(result).toEqual({ id: 'venue-1', name: 'Grand Hall' }); + }); + }); +}); diff --git a/client/src/tests/setup.ts b/client/src/tests/setup.ts new file mode 100644 index 0000000000..0d74b7352a --- /dev/null +++ b/client/src/tests/setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); diff --git a/client/src/tests/unit/utils/slotGenerator.test.ts b/client/src/tests/unit/utils/slotGenerator.test.ts new file mode 100644 index 0000000000..69160a1686 --- /dev/null +++ b/client/src/tests/unit/utils/slotGenerator.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { + timeToMinutes, + minutesToTime, + isTimeBlocked, + getPriceForTimeRange, + generateFlexibleSlots, + prepareFixedSlots, + generateSlots, +} from '@/utils/slotGenerator.utils'; +import type { ISlotConfig } from '@/utils/slotGenerator.types'; + +describe('client slotGenerator.utils', () => { + describe('timeToMinutes & minutesToTime', () => { + it('should convert time string to minutes and back', () => { + expect(timeToMinutes('09:30')).toBe(570); + expect(minutesToTime(570)).toBe('09:30'); + }); + }); + + describe('isTimeBlocked', () => { + it('should return false when blockedTimes is empty or undefined', () => { + expect(isTimeBlocked('09:00', '10:00', [])).toBe(false); + expect(isTimeBlocked('09:00', '10:00', undefined)).toBe(false); + }); + + it('should detect overlapping blocked time slots', () => { + const blockedTimes = [{ fromTime: '10:00', toTime: '12:00' }]; + expect(isTimeBlocked('09:00', '10:30', blockedTimes)).toBe(true); + expect(isTimeBlocked('11:30', '13:00', blockedTimes)).toBe(true); + expect(isTimeBlocked('08:00', '09:30', blockedTimes)).toBe(false); + }); + }); + + describe('getPriceForTimeRange', () => { + it('should return default price when no pricing rules are provided', () => { + expect(getPriceForTimeRange('09:00', '10:00', [], '500')).toBe(500); + }); + + it('should apply matching pricing rule price when slot falls within range', () => { + const pricingRules = [{ fromTime: '18:00', toTime: '22:00', price: 1500 }]; + expect(getPriceForTimeRange('19:00', '20:00', pricingRules, '1000')).toBe(1500); + }); + }); + + describe('generateFlexibleSlots & prepareFixedSlots', () => { + it('should generate flexible slots directly via generateFlexibleSlots', () => { + const config: ISlotConfig = { + bookingType: 'flexibleBooking', + workingDays: ['Monday', 'Tuesday'], + workingHours: { open: '09:00', close: '11:00' }, + slotDuration: '60', + bufferTime: '0', + samePrice: '1000', + }; + const slots = generateFlexibleSlots('2026-07-22', config); + expect(slots).toHaveLength(2); + }); + + it('should prepare fixed slots directly via prepareFixedSlots', () => { + const config: ISlotConfig = { + bookingType: 'fixedBooking', + workingDays: ['Monday', 'Tuesday'], + fixedPackages: [ + { slotName: 'Full Package', startTime: '09:00', endTime: '17:00', price: 10000 }, + ], + }; + const slots = prepareFixedSlots('2026-07-22', config); + expect(slots).toHaveLength(1); + expect(slots[0].name).toBe('Full Package'); + }); + }); + + describe('generateSlots', () => { + it('should generate flexible slots correctly within working hours', () => { + const config: ISlotConfig = { + bookingType: 'flexibleBooking', + workingDays: ['Monday', 'Tuesday'], + workingHours: { open: '09:00', close: '12:00' }, + slotDuration: '60', + bufferTime: '0', + samePrice: '1000', + }; + + const slots = generateSlots('2026-07-22', config); + expect(slots).toHaveLength(3); + expect(slots[0]).toEqual({ + id: 'slot-2026-07-22-0', + startTime: '09:00', + endTime: '10:00', + price: 1000, + isAvailable: true, + reason: undefined, + }); + }); + + it('should generate fixed package slots correctly', () => { + const config: ISlotConfig = { + bookingType: 'fixedBooking', + workingDays: ['Monday', 'Tuesday'], + fixedPackages: [ + { slotName: 'Morning Session', startTime: '08:00', endTime: '12:00', price: 2500 }, + { slotName: 'Evening Party', startTime: '16:00', endTime: '22:00', price: 5000 }, + ], + }; + + const slots = generateSlots('2026-07-22', config); + expect(slots).toHaveLength(2); + expect(slots[0].name).toBe('Morning Session'); + }); + }); +}); diff --git a/client/src/tests/unit/utils/timeUtils.test.ts b/client/src/tests/unit/utils/timeUtils.test.ts new file mode 100644 index 0000000000..9a24f9ccc4 --- /dev/null +++ b/client/src/tests/unit/utils/timeUtils.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { parseTimeToMinutes, formatMinutesToTime, toLocalDateString } from '@/utils/timeUtils'; + +describe('client timeUtils', () => { + describe('parseTimeToMinutes', () => { + it('should parse "00:00" to 0 minutes', () => { + expect(parseTimeToMinutes('00:00')).toBe(0); + }); + + it('should parse "14:30" to 870 minutes', () => { + expect(parseTimeToMinutes('14:30')).toBe(870); + }); + + it('should parse "23:59" to 1439 minutes', () => { + expect(parseTimeToMinutes('23:59')).toBe(1439); + }); + + it('should throw an error for invalid time formats', () => { + expect(() => parseTimeToMinutes('invalid')).toThrow('Invalid time format: invalid'); + }); + }); + + describe('formatMinutesToTime', () => { + it('should format 0 minutes to "00:00"', () => { + expect(formatMinutesToTime(0)).toBe('00:00'); + }); + + it('should format 870 minutes to "14:30"', () => { + expect(formatMinutesToTime(870)).toBe('14:30'); + }); + + it('should format 1439 minutes to "23:59"', () => { + expect(formatMinutesToTime(1439)).toBe('23:59'); + }); + }); + + describe('toLocalDateString', () => { + it('should format date object to YYYY-MM-DD format', () => { + const date = new Date(2026, 6, 22); // Month is 0-indexed (6 = July) + expect(toLocalDateString(date)).toBe('2026-07-22'); + }); + }); +}); diff --git a/client/src/tests/unit/utils/venueFormMapper.test.ts b/client/src/tests/unit/utils/venueFormMapper.test.ts new file mode 100644 index 0000000000..e99173337d --- /dev/null +++ b/client/src/tests/unit/utils/venueFormMapper.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { mapFormToDTO } from '@/utils/venueFormMapper'; +import type { AddVenueFormValues } from '@/types/venue.types'; + +describe('client venueFormMapper', () => { + describe('mapFormToDTO', () => { + it('should map fixed booking form values to DTO payload', () => { + const mockForm: AddVenueFormValues = { + VenueName: 'Royal Palace Hall', + VenueDescription: 'Grand event hall for luxury weddings', + venueType: 'Convention Center', + district: 'Ernakulam', + state: 'Kerala', + city: 'Kochi', + pincode: '682001', + fullAddress: 'MG Road, Kochi', + googleMapsLink: 'https://maps.google.com', + coordinates: { lat: 9.9312, lng: 76.2673 }, + spaceAttributes: ['AC', 'Parking'], + seatingConfigurations: [], + maxCapacity: '500', + bookingType: 'fixedBooking', + workingDays: ['Monday', 'Tuesday', 'Wednesday'], + fixedPackages: [ + { slotName: 'Full Day Package', startTime: '08:00', endTime: '22:00', price: 25000 }, + ], + workingHours: { open: '', close: '' }, + flexibleBooking: { slotDuration: '', bufferTime: '' }, + pricing: { pricingType: 'fixedPricing', basePrice: 25000, pricingRules: [] }, + pricingType: 'fixedPricing', + samePrice: 25000, + blockedTimes: [], + amenities: ['WiFi', 'Stage'], + venuePhotos: [], + existingImages: { coverImage: '', galleryImages: [] }, + contact: { name: 'Manager John', phone: '9876543210', email: 'john@example.com' }, + cancellation: { policy: 'nonRefundable', refundType: 'fullRefund', refundRules: [] }, + }; + + const dto = mapFormToDTO(mockForm, ['https://res.cloudinary.com/cover.jpg']); + + expect(dto.name).toBe('Royal Palace Hall'); + expect(dto.coverImage).toBe('https://res.cloudinary.com/cover.jpg'); + if ('fixedPackages' in dto) { + expect(dto.fixedPackages).toEqual([ + { slotName: 'Full Day Package', startTime: '08:00', endTime: '22:00', price: 25000 }, + ]); + } + expect(dto.maxCapacity).toBe(500); + }); + }); +}); diff --git a/client/src/tests/utils/redirect.test.ts b/client/src/tests/utils/redirect.test.ts new file mode 100644 index 0000000000..d265a3d830 --- /dev/null +++ b/client/src/tests/utils/redirect.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +describe('getSafeRedirectUrl', () => { + beforeEach(() => { + window.location = { origin: 'http://localhost:5173' } as unknown as Location; + }); + + it('should return fallback for null or undefined', async () => { + const { getSafeRedirectUrl } = await import('../../utils/redirect'); + expect(getSafeRedirectUrl(null)).toBe('/'); + expect(getSafeRedirectUrl(null, '/dashboard')).toBe('/dashboard'); + }); + + it('should return pathname for same-origin URL', async () => { + const { getSafeRedirectUrl } = await import('../../utils/redirect'); + const result = getSafeRedirectUrl('http://localhost:5173/venues/123'); + expect(result).toBe('/venues/123'); + }); + + it('should return pathname with search params for same-origin URL', async () => { + const { getSafeRedirectUrl } = await import('../../utils/redirect'); + const result = getSafeRedirectUrl('http://localhost:5173/search?q=hall'); + expect(result).toBe('/search?q=hall'); + }); + + it('should return fallback for external URL', async () => { + const { getSafeRedirectUrl } = await import('../../utils/redirect'); + expect(getSafeRedirectUrl('https://evil.com/phish')).toBe('/'); + }); + + it('should treat bare string as same-origin relative URL', async () => { + const { getSafeRedirectUrl } = await import('../../utils/redirect'); + expect(getSafeRedirectUrl('not a url')).toBe('/not%20a%20url'); + }); + + it('should use custom fallback when provided', async () => { + const { getSafeRedirectUrl } = await import('../../utils/redirect'); + expect(getSafeRedirectUrl('https://evil.com', '/safe')).toBe('/safe'); + }); +}); diff --git a/client/src/tests/utils/timeUtils.test.ts b/client/src/tests/utils/timeUtils.test.ts new file mode 100644 index 0000000000..f887f78fd2 --- /dev/null +++ b/client/src/tests/utils/timeUtils.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; + +describe('timeUtils', () => { + describe('parseTimeToMinutes', () => { + it('should parse "14:30" to 870', async () => { + const { parseTimeToMinutes } = await import('../../utils/timeUtils'); + expect(parseTimeToMinutes('14:30')).toBe(870); + }); + + it('should parse "00:00" to 0', async () => { + const { parseTimeToMinutes } = await import('../../utils/timeUtils'); + expect(parseTimeToMinutes('00:00')).toBe(0); + }); + + it('should parse "23:59" to 1439', async () => { + const { parseTimeToMinutes } = await import('../../utils/timeUtils'); + expect(parseTimeToMinutes('23:59')).toBe(1439); + }); + + it('should throw on invalid format', async () => { + const { parseTimeToMinutes } = await import('../../utils/timeUtils'); + expect(() => parseTimeToMinutes('invalid')).toThrow('Invalid time format: invalid'); + }); + + it('should throw on empty string', async () => { + const { parseTimeToMinutes } = await import('../../utils/timeUtils'); + expect(() => parseTimeToMinutes('')).toThrow(); + }); + }); + + describe('formatMinutesToTime', () => { + it('should format 870 to "14:30"', async () => { + const { formatMinutesToTime } = await import('../../utils/timeUtils'); + expect(formatMinutesToTime(870)).toBe('14:30'); + }); + + it('should format 0 to "00:00"', async () => { + const { formatMinutesToTime } = await import('../../utils/timeUtils'); + expect(formatMinutesToTime(0)).toBe('00:00'); + }); + + it('should format 1439 to "23:59"', async () => { + const { formatMinutesToTime } = await import('../../utils/timeUtils'); + expect(formatMinutesToTime(1439)).toBe('23:59'); + }); + + it('should pad single digit hours and minutes', async () => { + const { formatMinutesToTime } = await import('../../utils/timeUtils'); + expect(formatMinutesToTime(1)).toBe('00:01'); + expect(formatMinutesToTime(60)).toBe('01:00'); + }); + }); + + describe('toLocalDateString', () => { + it('should format Date to YYYY-MM-DD', async () => { + const { toLocalDateString } = await import('../../utils/timeUtils'); + const date = new Date(2026, 0, 15); + expect(toLocalDateString(date)).toBe('2026-01-15'); + }); + + it('should pad month and day with zeros', async () => { + const { toLocalDateString } = await import('../../utils/timeUtils'); + const date = new Date(2026, 2, 5); + expect(toLocalDateString(date)).toBe('2026-03-05'); + }); + + it('should handle December dates correctly', async () => { + const { toLocalDateString } = await import('../../utils/timeUtils'); + const date = new Date(2025, 11, 25); + expect(toLocalDateString(date)).toBe('2025-12-25'); + }); + }); +}); diff --git a/client/src/tests/utils/toast.test.ts b/client/src/tests/utils/toast.test.ts new file mode 100644 index 0000000000..7c2d1cd739 --- /dev/null +++ b/client/src/tests/utils/toast.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; + +const mockSuccess = vi.fn(); +const mockError = vi.fn(); +const mockToast = vi.fn(); + +vi.mock('react-hot-toast', () => ({ + default: Object.assign(mockToast, { success: mockSuccess, error: mockError }), +})); + +describe('toast utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('showSuccess', () => { + it('should call toast.success with message and options', async () => { + const { showSuccess } = await import('../../utils/toast'); + showSuccess('Operation successful'); + expect(mockSuccess).toHaveBeenCalledWith( + 'Operation successful', + expect.objectContaining({ duration: 3000 }) + ); + }); + }); + + describe('showError', () => { + it('should call toast.error with message and options', async () => { + const { showError } = await import('../../utils/toast'); + showError('Something went wrong'); + expect(mockError).toHaveBeenCalledWith( + 'Something went wrong', + expect.objectContaining({ duration: 4000 }) + ); + }); + }); + + describe('showInfo', () => { + it('should call toast with message and options', async () => { + const { showInfo } = await import('../../utils/toast'); + showInfo('Information'); + expect(mockToast).toHaveBeenCalledWith( + 'Information', + expect.objectContaining({ duration: 3000 }) + ); + }); + }); + + describe('extractErrorMessage', () => { + it('should extract message from Axios-like error response data', async () => { + const { extractErrorMessage } = await import('../../utils/toast'); + const error = { response: { data: { message: 'Email already exists' } } }; + expect(extractErrorMessage(error)).toBe('Email already exists'); + }); + + it('should extract error field from response data', async () => { + const { extractErrorMessage } = await import('../../utils/toast'); + const error = { response: { data: { error: 'Unauthorized' } } }; + expect(extractErrorMessage(error)).toBe('Unauthorized'); + }); + + it('should fall back to error.message', async () => { + const { extractErrorMessage } = await import('../../utils/toast'); + const error = new Error('Network Error'); + expect(extractErrorMessage(error)).toBe('Network Error'); + }); + + it('should use fallback when no message is available', async () => { + const { extractErrorMessage } = await import('../../utils/toast'); + expect(extractErrorMessage({}, 'Default fallback')).toBe('Default fallback'); + }); + + it('should use default fallback when none provided', async () => { + const { extractErrorMessage } = await import('../../utils/toast'); + expect(extractErrorMessage(null)).toBe('An unexpected error occurred'); + }); + + it('should handle non-object errors', async () => { + const { extractErrorMessage } = await import('../../utils/toast'); + expect(extractErrorMessage('string error')).toBe('An unexpected error occurred'); + }); + }); +}); diff --git a/client/src/tests/utils/venueDraft.test.ts b/client/src/tests/utils/venueDraft.test.ts new file mode 100644 index 0000000000..b55b2693de --- /dev/null +++ b/client/src/tests/utils/venueDraft.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +describe('venueDraft', () => { + const userId = 'user-123'; + + beforeEach(() => { + sessionStorage.clear(); + }); + + describe('saveDraftSession / loadDraftSession', () => { + it('should save and load draft session', async () => { + const { saveDraftSession, loadDraftSession } = await import('../../utils/venueDraft'); + const data = { + venueId: 'venue-1', + step: 1, + formValues: { VenueName: 'Test' } as Record, + }; + saveDraftSession(userId, data); + const loaded = loadDraftSession(userId); + expect(loaded).toEqual(data); + }); + + it('should return null when no draft exists', async () => { + const { loadDraftSession } = await import('../../utils/venueDraft'); + expect(loadDraftSession(userId)).toBeNull(); + }); + }); + + describe('clearDraftSession', () => { + it('should remove draft from sessionStorage', async () => { + const { saveDraftSession, clearDraftSession, loadDraftSession } = + await import('../../utils/venueDraft'); + saveDraftSession(userId, { + venueId: 'v1', + step: 0, + formValues: {} as Record, + }); + clearDraftSession(userId); + expect(loadDraftSession(userId)).toBeNull(); + }); + }); + + describe('legacy helpers', () => { + it('saveDraft should store serialized data', async () => { + const { saveDraft, loadDraft } = await import('../../utils/venueDraft'); + saveDraft(userId, { name: 'Test' }); + const loaded = loadDraft<{ name: string }>(userId); + expect(loaded).toEqual({ name: 'Test' }); + }); + + it('clearDraft should remove legacy draft', async () => { + const { saveDraft, clearDraft, loadDraft } = await import('../../utils/venueDraft'); + saveDraft(userId, { name: 'Test' }); + clearDraft(userId); + expect(loadDraft(userId)).toBeNull(); + }); + + it('loadDraft should return null for missing key', async () => { + const { loadDraft } = await import('../../utils/venueDraft'); + expect(loadDraft('nonexistent')).toBeNull(); + }); + }); +}); diff --git a/client/src/tests/utils/venueFormMapper.test.ts b/client/src/tests/utils/venueFormMapper.test.ts new file mode 100644 index 0000000000..3be3b1b07e --- /dev/null +++ b/client/src/tests/utils/venueFormMapper.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; + +function createBaseFormValues(overrides = {}) { + return { + VenueName: 'Grand Hall', + VenueDescription: 'A beautiful venue', + fullAddress: '123 Main St', + googleMapsLink: '', + venueType: 'hall', + district: 'Ernakulam', + state: 'Kerala', + city: 'Kochi', + pincode: '682001', + spaceAttributes: ['ac'], + seatingConfigurations: ['theatre'], + maxCapacity: '200', + bookingType: 'fixedBooking', + workingDays: ['monday', 'friday'], + amenities: ['parking', 'wifi'], + contact: { name: 'John', phone: '9876543210', email: '' }, + cancellation: { + policy: 'refundable', + refundType: 'fullRefund', + refundRules: [{ daysBefore: '7', refundPercentage: '100' }], + }, + fixedPackages: [{ slotName: 'Morning', startTime: '09:00', endTime: '12:00', price: '5000' }], + workingHours: { open: '09:00', close: '18:00' }, + flexibleBooking: { slotDuration: '60', bufferTime: '15' }, + pricing: { pricingType: 'fixedPricing', basePrice: '0', pricingRules: [] }, + blockedTimes: [], + pricingType: 'fixedPricing', + samePrice: '10000', + venuePhotos: [], + ...overrides, + }; +} + +describe('mapFormToDTO', () => { + it('should map fixed booking form values to DTO', async () => { + const { mapFormToDTO } = await import('../../utils/venueFormMapper'); + const values = createBaseFormValues(); + const result = mapFormToDTO(values as Record, ['cover.jpg', 'gallery1.jpg']); + + expect(result.name).toBe('Grand Hall'); + expect(result.description).toBe('A beautiful venue'); + expect(result.address).toBe('123 Main St'); + expect(result.venueType).toBe('hall'); + expect(result.bookingType).toBe('fixedBooking'); + expect(result.fixedPackages).toHaveLength(1); + expect(result.fixedPackages[0]).toEqual({ + slotName: 'Morning', + startTime: '09:00', + endTime: '12:00', + price: 5000, + }); + expect(result.coverImage).toBe('cover.jpg'); + expect(result.galleryImages).toEqual(['gallery1.jpg']); + expect(result.maxCapacity).toBe(200); + expect(result.contact.name).toBe('John'); + expect(result.cancellation.refundRules[0]).toEqual({ daysBefore: 7, refundPercentage: 100 }); + }); + + it('should map flexible booking form values to DTO', async () => { + const { mapFormToDTO } = await import('../../utils/venueFormMapper'); + const values = createBaseFormValues({ + bookingType: 'flexibleBooking', + fixedPackages: [], + samePrice: undefined, + pricingType: 'timeBasedPricing', + pricing: { + pricingType: 'timeBasedPricing', + basePrice: '2000', + pricingRules: [ + { fromTime: '09:00', toTime: '12:00', price: '5000' }, + { fromTime: '', toTime: '', price: '' }, + ], + }, + }); + const result = mapFormToDTO(values as Record, ['cover.jpg']); + + expect(result.bookingType).toBe('flexibleBooking'); + expect(result.fixedPackages).toBeUndefined(); + expect(result.workingHours).toEqual({ open: '09:00', close: '18:00' }); + expect(result.flexibleBooking).toEqual({ slotDuration: 60, bufferTime: 15 }); + expect(result.pricing.pricingType).toBe('timeBasedPricing'); + expect(result.pricing.basePrice).toBe(2000); + expect(result.pricing.pricingRules).toHaveLength(1); + expect(result.pricing.pricingRules[0]).toEqual({ + fromTime: '09:00', + toTime: '12:00', + price: 5000, + }); + }); + + it('should handle fixedPricing with samePrice for flexible booking', async () => { + const { mapFormToDTO } = await import('../../utils/venueFormMapper'); + const values = createBaseFormValues({ + bookingType: 'flexibleBooking', + fixedPackages: [], + pricingType: 'fixedPricing', + samePrice: '15000', + pricing: { + pricingType: 'fixedPricing', + basePrice: '0', + pricingRules: [], + }, + }); + const result = mapFormToDTO(values as Record, ['cover.jpg']); + expect(result.pricing.pricingType).toBe('fixedPricing'); + expect(result.pricing.basePrice).toBe(15000); + expect(result.pricing.pricingRules).toEqual([]); + }); + + it('should omit googleMapsUrl when link is empty', async () => { + const { mapFormToDTO } = await import('../../utils/venueFormMapper'); + const values = createBaseFormValues(); + const result = mapFormToDTO(values as Record, ['cover.jpg']); + expect(result.googleMapsUrl).toBeUndefined(); + }); + + it('should include googleMapsUrl when link is provided', async () => { + const { mapFormToDTO } = await import('../../utils/venueFormMapper'); + const values = createBaseFormValues({ googleMapsLink: 'https://maps.google.com/xyz' }); + const result = mapFormToDTO(values as Record, ['cover.jpg']); + expect(result.googleMapsUrl).toBe('https://maps.google.com/xyz'); + }); + + it('should filter empty refund rules', async () => { + const { mapFormToDTO } = await import('../../utils/venueFormMapper'); + const values = createBaseFormValues({ + cancellation: { + policy: 'refundable', + refundType: 'timeBasedRefund', + refundRules: [ + { daysBefore: '7', refundPercentage: '100' }, + { daysBefore: '', refundPercentage: '' }, + ], + }, + }); + const result = mapFormToDTO(values as Record, ['cover.jpg']); + expect(result.cancellation.refundRules).toHaveLength(1); + }); + + it('should handle empty maxCapacity', async () => { + const { mapFormToDTO } = await import('../../utils/venueFormMapper'); + const values = createBaseFormValues({ maxCapacity: '' }); + const result = mapFormToDTO(values as Record, ['cover.jpg']); + expect(result.maxCapacity).toBeUndefined(); + }); + + it('should set default slotDuration and bufferTime when empty', async () => { + const { mapFormToDTO } = await import('../../utils/venueFormMapper'); + const values = createBaseFormValues({ + bookingType: 'flexibleBooking', + fixedPackages: [], + flexibleBooking: { slotDuration: '', bufferTime: '' }, + samePrice: '5000', + pricingType: 'fixedPricing', + pricing: { pricingType: 'fixedPricing', basePrice: '0', pricingRules: [] }, + }); + const result = mapFormToDTO(values as Record, ['cover.jpg']); + expect(result.flexibleBooking.slotDuration).toBe(60); + expect(result.flexibleBooking.bufferTime).toBe(0); + }); +}); diff --git a/client/src/types/auth.types.ts b/client/src/types/auth.types.ts new file mode 100644 index 0000000000..ca39b86833 --- /dev/null +++ b/client/src/types/auth.types.ts @@ -0,0 +1,19 @@ +export interface SessionSummary { + id: string; + ipAddress: string; + userAgent: string; + lastLogin: string; + createdAt: string; + isCurrent: boolean; +} + +export interface LoginResponse { + userId?: string; + id?: string; + username: string; + email: string; +} + +export interface AuthMessageResponse { + message?: string; +} diff --git a/client/src/types/booking.types.ts b/client/src/types/booking.types.ts new file mode 100644 index 0000000000..8dcb90a6d1 --- /dev/null +++ b/client/src/types/booking.types.ts @@ -0,0 +1,129 @@ +export interface CheckoutResponse { + orderId: string; + amount: number; + currency: string; +} + +export interface RazorpaySuccessResponse { + razorpay_payment_id: string; + razorpay_order_id: string; + razorpay_signature: string; +} + +export interface RazorpayErrorResponse { + error: { + code: string; + description: string; + source: string; + step: string; + reason: string; + metadata: { + order_id: string; + payment_id: string; + }; + }; +} + +export interface Slot { + slotId: string; + name: string | null; + startTime: string; + endTime: string; + price: number; + isAvailable: boolean; + reason: string | null; +} + +export interface BookingCardDTO { + _id: string; + bookingRef: string; + venueName: string; + venueId: string; + city: string; + district: string; + coverImage: string; + date: string; + timeRange: string; + bookedOn: string; + guestCount?: number; + eventType?: string; + totalPrice: number; + paymentMethod?: string; + paymentStatus?: 'pending' | 'paid' | 'refunded'; + uiStatus: 'upcoming' | 'completed' | 'cancelled' | 'in_progress'; + hasReview?: boolean; +} + +export interface BookingDetailDTO extends BookingCardDTO { + address: string; + contactPhone: string; + contactEmail?: string; + googleMapsUrl?: string; + amenities: string[]; + cancellationPolicy: string; + cancellationRefundPct?: number; + paymentReference: string; + bookerInfo?: { + name: string; + email: string; + phone: string; + place: string; + note?: string; + }; +} + +export interface CancelBookingResponse { + refundAmount: number; +} + +export interface BookerInfoForm { + guestCount: number; + eventType: string; + name: string; + email: string; + phone: string; + place: string; + note?: string; +} + +export interface ApiResponse { + success: boolean; + data: T; + message?: string; +} + +export interface VerifyPaymentPayload { + orderId: string; + paymentId: string; + signature: string; +} + +export interface VerifyPaymentResult { + _id: string; + bookingRef: string; +} + +export interface LockSlotPayload { + date: string; + startTime: string; + endTime: string; +} + +export interface LockSlotResponse { + lockId: string; + expiresAt: string; + amountToPay: number; +} + +export interface BookableDatesResponse { + bookableDates: string[]; + disabledDates: string[]; + maxDate: string; +} + +export interface DayAvailabilityResponse { + venueId: string; + date: string; + bookingType: string; + slots: Slot[]; +} diff --git a/client/src/types/geo.types.ts b/client/src/types/geo.types.ts new file mode 100644 index 0000000000..5b91cf5dfe --- /dev/null +++ b/client/src/types/geo.types.ts @@ -0,0 +1,21 @@ +export interface GeoSearchResult { + displayName: string; + lat: number; + lng: number; + city?: string; + district?: string; + postcode?: string; + boundingbox?: [number, number, number, number]; // [south, north, west, east] +} + +export interface Coordinates { + lat: number | string; + lng: number | string; +} + +export interface VenuePinsBBox { + swLng: number; + swLat: number; + neLng: number; + neLat: number; +} diff --git a/client/src/types/review.types.ts b/client/src/types/review.types.ts new file mode 100644 index 0000000000..dd140cd788 --- /dev/null +++ b/client/src/types/review.types.ts @@ -0,0 +1,39 @@ +export interface ReviewDTO { + rating?: number; + comment?: string; +} + +export interface UpdateReviewDTO { + rating?: number; + comment?: string; +} + +export interface Review { + _id: string; + venueId: string; + user: { + id: string; + userName: string; + isVerified?: boolean; + }; + rating?: number; + comment?: string; + createdAt: string; + editedAt?: string; + reviewerRating?: number; + ownerReply?: { + text: string; + repliedAt: string; + }; +} + +export interface ReviewsResponse { + reviews: Review[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }; +} diff --git a/client/src/types/user.types.ts b/client/src/types/user.types.ts new file mode 100644 index 0000000000..98478addd4 --- /dev/null +++ b/client/src/types/user.types.ts @@ -0,0 +1,36 @@ +export interface User { + id: string; + username: string; + email: string; + profilePicture?: string; +} + +export interface UpdateProfileDto { + username?: string; + profilePicturePublicId?: string; +} + +export interface CloudinarySignatureResponse { + signature: string; + timestamp: number; + cloudName: string; + apiKey: string; + uploadPreset: string; + folder: string; +} + +export interface UpdatedProfile { + _id: string; + name: string; + email: string; + profilePicture?: string; +} + +export interface ProfileResponse { + _id?: string; + id?: string; + username?: string; + name?: string; + email: string; + profilePicture?: string; +} diff --git a/client/src/types/venue.types.ts b/client/src/types/venue.types.ts new file mode 100644 index 0000000000..05f9006f31 --- /dev/null +++ b/client/src/types/venue.types.ts @@ -0,0 +1,262 @@ +export interface IFixedPackage { + slotName: string; + startTime: string; + endTime: string; + price: number; +} + +export interface IPricingRule { + fromTime: string; + toTime: string; + price: number; +} + +export interface IBlockedTime { + fromTime: string; + toTime: string; + reason: string; +} + +export interface IPricing { + pricingType: 'fixedPricing' | 'timeBasedPricing'; + basePrice: number; + pricingRules: IPricingRule[]; +} + +export interface IRefundRule { + daysBefore: number; + refundPercentage: number; +} + +export interface IContact { + name: string; + phone: string; + email?: string | null; +} + +export interface ICancellation { + policy: 'refundable' | 'nonRefundable'; + refundType?: 'fullRefund' | 'timeBasedRefund'; + refundRules: IRefundRule[]; +} + +// ─── Form-only types (pre-coercion, values may be strings) ────────────────── + +export interface IAddVenueFixedPackage { + slotName: string; + startTime: string; + endTime: string; + price: number | string; +} + +export interface IAddVenuePricingRule { + fromTime: string; + toTime: string; + price: number | string; +} + +export interface IAddVenueRefundRule { + daysBefore: number | string; + refundPercentage: number | string; +} + +export interface AddVenueFormValues { + VenueName: string; + VenueDescription: string; + venueType: string; + district: string; + state: string; + city: string; + pincode: string; + fullAddress: string; + googleMapsLink: string; + coordinates?: { + lat: number | string; + lng: number | string; + } | null; + spaceAttributes: string[]; + seatingConfigurations: string[]; + maxCapacity: string; + bookingType: string; + fixedPackages: IAddVenueFixedPackage[]; + workingDays: string[]; + workingHours: { + open: string; + close: string; + }; + + flexibleBooking: { + slotDuration: number | string; + bufferTime: number | string; + }; + + pricing: { + pricingType: string; + basePrice: number | string; + pricingRules: IAddVenuePricingRule[]; + }; + blockedTimes: IBlockedTime[]; + pricingType?: string; + samePrice?: number | string; + amenities: string[]; + venuePhotos: File[]; + existingImages: { + coverImage: string; + galleryImages: string[]; + }; + + contact: { + name: string; + phone: string; + email?: string; + }; + + cancellation: { + policy: string; + refundType: string; + refundRules: IAddVenueRefundRule[]; + }; +} + +export type VenueStatus = 'Draft' | 'PendingReview' | 'Approved' | 'Rejected' | 'Suspended'; + +export interface MyVenue { + _id: string; + name: string; + city: string; + district: string; + state: string; + venueType: string; + coverImage: string; + status: VenueStatus; + rejectionReason?: string; + rejectionHistory?: RejectionEntry[]; + submissionCount?: number; + currentEditDeadline?: string; + suspensionReason?: string; + createdAt: string; + isFeatured?: boolean; +} + +export interface RejectionEntry { + reason: string; + rejectedAt: string; + rejectedBy?: string; + submissionNumber: number; + editDeadline: string; + extendedAt?: string; + extendedBy?: string; + originalDeadline?: string; +} + +export interface VenueDetail { + _id: string; + name: string; + description: string; + venueType: string; + address: string; + city: string; + state: string; + district: string; + pincode: string; + googleMapsUrl?: string; + location?: { + coordinates: [number, number]; + }; + spaceAttributes: string[]; + seatingConfigurations: string[]; + maxCapacity?: number; + bookingType: 'fixedBooking' | 'flexibleBooking'; + fixedPackages: IFixedPackage[]; + workingDays: string[]; + workingHours: { + open: string; + close: string; + }; + flexibleBooking?: { + slotDuration: number; + bufferTime: number; + }; + pricing: IPricing; + blockedTimes?: IBlockedTime[]; + amenities: string[]; + coverImage: string; + galleryImages: string[]; + contact: IContact; + cancellation: ICancellation; + status: VenueStatus; + createdAt: string; + avgRating?: number; + reviewCount?: number; +} + +export interface PublicVenue { + _id: string; + name: string; + description: string; + venueType: string; + city: string; + district: string; + coverImage: string; + maxCapacity: number; + flexibleBooking?: { + slotDuration: number; + bufferTime: number; + }; + amenities?: string[]; + bookingType?: 'fixedBooking' | 'flexibleBooking'; + pricing?: IPricing; + fixedPackages?: { price: number }[]; + avgRating?: number; + reviewCount?: number; +} + +export interface VenueFilters { + searchTerm?: string; + minPrice?: number; + maxPrice?: number; + venueType?: string[]; + district?: string; + capacity?: number; + spaceAttributes?: string[]; + seatingConfigurations?: string[]; + amenities?: string[]; + sortBy?: 'price-low' | 'price-high' | 'rating'; +} + +export interface PaginationMeta { + total: number; + page: number; + limit: number; + skip: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} + +export interface PaginatedVenuesResponse { + venues: PublicVenue[]; + pagination: PaginationMeta; +} + +export interface MyVenuesResponse { + count: number; + venues: MyVenue[]; +} + +export interface VenuePin { + _id: string; + name: string; + location: { + coordinates: [number, number]; // [lng, lat] + }; + coverImage: string; + avgRating: number; +} + +export interface VenuePinsBBox { + swLng: number; + swLat: number; + neLng: number; + neLat: number; +} diff --git a/client/src/types/wishlist.types.ts b/client/src/types/wishlist.types.ts new file mode 100644 index 0000000000..c7657e6026 --- /dev/null +++ b/client/src/types/wishlist.types.ts @@ -0,0 +1,30 @@ +export interface WishlistResponse { + wishlisted: boolean; +} + +export interface WishlistStatusResponse { + [venueId: string]: boolean; +} + +export interface WishlistVenue { + _id: string; + name: string; + city: string; + district: string; + coverImage: string; + maxCapacity?: number; + avgRating: number; + reviewCount: number; +} + +export interface WishlistPagination { + total: number; + page: number; + totalPages: number; + hasMore: boolean; +} + +export interface WishlistListResponse { + venues: WishlistVenue[]; + pagination: WishlistPagination; +} diff --git a/client/src/utils/bookingUtils.ts b/client/src/utils/bookingUtils.ts new file mode 100644 index 0000000000..4dd6434dc7 --- /dev/null +++ b/client/src/utils/bookingUtils.ts @@ -0,0 +1,7 @@ +export function minutesToTime(minutes: number): string { + const h = Math.floor(minutes / 60) + .toString() + .padStart(2, '0'); + const m = (minutes % 60).toString().padStart(2, '0'); + return `${h}:${m}`; +} diff --git a/client/src/utils/cropImage.ts b/client/src/utils/cropImage.ts new file mode 100644 index 0000000000..9c68580dff --- /dev/null +++ b/client/src/utils/cropImage.ts @@ -0,0 +1,61 @@ +import { MAX_AVATAR_FILE_SIZE } from '@/constants/upload'; + +const MAX_OUTPUT_BYTES = MAX_AVATAR_FILE_SIZE; +const QUALITY_STEPS = [0.92, 0.8, 0.6]; + +interface CropPixels { + x: number; + y: number; + width: number; + height: number; +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('Failed to load image for cropping')); + image.src = src; + }); +} + +function canvasToBlob(canvas: HTMLCanvasElement, quality: number): Promise { + return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality)); +} + +export async function getCroppedImageBlob( + imageSrc: string, + cropPixels: CropPixels, + outputSize = 512 +): Promise { + const image = await loadImage(imageSrc); + + const canvas = document.createElement('canvas'); + canvas.width = outputSize; + canvas.height = outputSize; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Canvas is not supported in this browser'); + } + + ctx.drawImage( + image, + cropPixels.x, + cropPixels.y, + cropPixels.width, + cropPixels.height, + 0, + 0, + outputSize, + outputSize + ); + + for (const quality of QUALITY_STEPS) { + const blob = await canvasToBlob(canvas, quality); + if (blob && blob.size <= MAX_OUTPUT_BYTES) { + return blob; + } + } + + throw new Error('Could not compress the cropped image small enough to upload'); +} diff --git a/client/src/utils/parseUserAgent.ts b/client/src/utils/parseUserAgent.ts new file mode 100644 index 0000000000..387c9027c7 --- /dev/null +++ b/client/src/utils/parseUserAgent.ts @@ -0,0 +1,27 @@ +export function parseUserAgent(userAgent: string): string { + const ua = userAgent.toLowerCase(); + + const os = ua.includes('windows') + ? 'Windows' + : ua.includes('mac os') + ? 'macOS' + : ua.includes('android') + ? 'Android' + : ua.includes('iphone') || ua.includes('ipad') + ? 'iOS' + : ua.includes('linux') + ? 'Linux' + : 'Unknown device'; + + const browser = ua.includes('edg/') + ? 'Edge' + : ua.includes('chrome/') + ? 'Chrome' + : ua.includes('firefox/') + ? 'Firefox' + : ua.includes('safari/') + ? 'Safari' + : 'Unknown browser'; + + return `${browser} on ${os}`; +} diff --git a/client/src/utils/profileGreeting.ts b/client/src/utils/profileGreeting.ts new file mode 100644 index 0000000000..1ac67d9a60 --- /dev/null +++ b/client/src/utils/profileGreeting.ts @@ -0,0 +1,11 @@ +let played = false; + +export const hasPlayedProfileGreeting = () => played; + +export const markProfileGreetingPlayed = () => { + played = true; +}; + +export const resetProfileGreeting = () => { + played = false; +}; diff --git a/client/src/utils/redirect.ts b/client/src/utils/redirect.ts new file mode 100644 index 0000000000..612d69104f --- /dev/null +++ b/client/src/utils/redirect.ts @@ -0,0 +1,14 @@ +export const getSafeRedirectUrl = (url: string | null, fallback = '/'): string => { + if (!url) return fallback; + + try { + const parsed = new URL(url, window.location.origin); + if (parsed.origin === window.location.origin) { + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } + } catch { + // URL parsing failed + } + + return fallback; +}; diff --git a/client/src/utils/slotGenerator.types.ts b/client/src/utils/slotGenerator.types.ts new file mode 100644 index 0000000000..35b6c5e00f --- /dev/null +++ b/client/src/utils/slotGenerator.types.ts @@ -0,0 +1,47 @@ +export interface IPreviewSlot { + id: string; + startTime: string; // HH:MM format + endTime: string; // HH:MM format + name?: string; + price: number; + isAvailable: boolean; + reason?: string; +} + +export interface IFixedPackage { + slotName: string; + startTime: string; + endTime: string; + price: number; +} + +export interface IPricingRule { + fromTime: string; + toTime: string; + price: number; +} + +export interface IBlockedTime { + fromTime: string; + toTime: string; + reason?: string; +} + +export interface IWorkingHours { + open: string; + close: string; +} + +export interface ISlotConfig { + bookingType: 'fixedBooking' | 'flexibleBooking'; + workingDays: string[]; + workingHours?: IWorkingHours; + fixedPackages?: IFixedPackage[]; + slotDuration?: string; + bufferTime?: string; + pricingType?: string; + pricingRules?: IPricingRule[]; + blockedTimes?: IBlockedTime[]; + samePrice?: string; + basePrice?: string; +} diff --git a/client/src/utils/slotGenerator.utils.ts b/client/src/utils/slotGenerator.utils.ts new file mode 100644 index 0000000000..7447559920 --- /dev/null +++ b/client/src/utils/slotGenerator.utils.ts @@ -0,0 +1,176 @@ +import type { ISlotConfig, IPreviewSlot, IBlockedTime, IPricingRule } from './slotGenerator.types'; + +export const timeToMinutes = (time: string): number => { + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; +}; + +export const minutesToTime = (minutes: number): string => { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +}; + +export const isTimeBlocked = ( + startTime: string, + endTime: string, + blockedTimes?: IBlockedTime[] +): boolean => { + if (!blockedTimes || blockedTimes.length === 0) { + return false; + } + + const rangeStart = timeToMinutes(startTime); + const rangeEnd = timeToMinutes(endTime); + + return blockedTimes.some((blocked) => { + const blockedStart = timeToMinutes(blocked.fromTime); + const blockedEnd = timeToMinutes(blocked.toTime); + + // Check if ranges overlap: ranges overlap if one starts before the other ends + return rangeStart < blockedEnd && rangeEnd > blockedStart; + }); +}; + +export const getPriceForTimeRange = ( + startTime: string, + endTime: string, + pricingRules?: IPricingRule[], + defaultPrice: string = '0' +): number => { + if (!pricingRules || pricingRules.length === 0) { + return parseFloat(defaultPrice) || 0; + } + + const slotStart = timeToMinutes(startTime); + const slotEnd = timeToMinutes(endTime); + + for (const rule of pricingRules) { + const ruleStart = timeToMinutes(rule.fromTime); + const ruleEnd = timeToMinutes(rule.toTime); + + // Check if the pricing rule covers the entire slot + if (ruleStart <= slotStart && slotEnd <= ruleEnd) { + return rule.price; + } + } + + return parseFloat(defaultPrice) || 0; +}; + +export const isWorkingDay = (date: Date, workingDays: string[]): boolean => { + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const dayName = dayNames[date.getDay()]; + return workingDays.includes(dayName); +}; + +export const getNextWorkingDay = (startDate: Date, workingDays: string[]): Date => { + const maxDays = 90; + const currentDate = new Date(startDate); + + for (let i = 0; i < maxDays; i++) { + if (isWorkingDay(currentDate, workingDays)) { + return currentDate; + } + currentDate.setDate(currentDate.getDate() + 1); + } + + return currentDate; +}; + +export const formatDateToString = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +export const parseDateString = (dateStr: string): Date => { + const [year, month, day] = dateStr.split('-').map(Number); + return new Date(year, month - 1, day); +}; + +export const generateFlexibleSlots = (date: string, config: ISlotConfig): IPreviewSlot[] => { + const slots: IPreviewSlot[] = []; + + if (!config.workingHours || !config.slotDuration) { + return slots; + } + + const slotDurationMinutes = parseInt(config.slotDuration, 10); + if (isNaN(slotDurationMinutes) || slotDurationMinutes <= 0) { + return slots; + } + const openTime = timeToMinutes(config.workingHours.open); + const closeTime = timeToMinutes(config.workingHours.close); + + if (isNaN(openTime) || isNaN(closeTime)) { + return slots; + } + + const bufferTimeMinutes = config.bufferTime ? parseInt(config.bufferTime, 10) : 0; + const safeBufferTime = isNaN(bufferTimeMinutes) ? 0 : bufferTimeMinutes; + + let currentTime = openTime; + let slotIndex = 0; + + while (currentTime + slotDurationMinutes <= closeTime) { + const startTime = minutesToTime(currentTime); + const endTime = minutesToTime(currentTime + slotDurationMinutes); + + const isBlocked = isTimeBlocked(startTime, endTime, config.blockedTimes); + const defaultPrice = + config.pricingType === 'timeBasedPricing' ? config.basePrice || '0' : config.samePrice || '0'; + + const price = getPriceForTimeRange(startTime, endTime, config.pricingRules, defaultPrice); + + const slot: IPreviewSlot = { + id: `slot-${date}-${slotIndex}`, + startTime, + endTime, + price, + isAvailable: !isBlocked, + reason: isBlocked ? 'This time slot is blocked' : undefined, + }; + + slots.push(slot); + currentTime += slotDurationMinutes + safeBufferTime; + slotIndex++; + } + + return slots; +}; + +export const prepareFixedSlots = (date: string, config: ISlotConfig): IPreviewSlot[] => { + const slots: IPreviewSlot[] = []; + + if (!config.fixedPackages || config.fixedPackages.length === 0) { + return slots; + } + + config.fixedPackages.forEach((pkg, index) => { + const isBlocked = isTimeBlocked(pkg.startTime, pkg.endTime, config.blockedTimes); + + const slot: IPreviewSlot = { + id: `fixed-${date}-${index}`, + startTime: pkg.startTime, + endTime: pkg.endTime, + name: pkg.slotName, + price: pkg.price, + isAvailable: !isBlocked, + reason: isBlocked ? 'This package is unavailable' : undefined, + }; + + slots.push(slot); + }); + + return slots; +}; + +export const generateSlots = (date: string, config: ISlotConfig): IPreviewSlot[] => { + if (config.bookingType === 'fixedBooking') { + return prepareFixedSlots(date, config); + } + + return generateFlexibleSlots(date, config); +}; diff --git a/client/src/utils/timeUtils.ts b/client/src/utils/timeUtils.ts new file mode 100644 index 0000000000..fa356532f5 --- /dev/null +++ b/client/src/utils/timeUtils.ts @@ -0,0 +1,25 @@ +// Parses a "HH:MM" string into minutes from midnight. +// Example: "14:30" -> 870 +export function parseTimeToMinutes(timeString: string): number { + const [hours, minutes] = timeString.split(':').map((str) => parseInt(str, 10)); + if (isNaN(hours) || isNaN(minutes)) { + throw new Error(`Invalid time format: ${timeString}`); + } + return hours * 60 + minutes; +} + +// Formats minutes from midnight into "HH:MM". +// Example: 870 -> "14:30" +export function formatMinutesToTime(minutesFromMidnight: number): string { + const h = Math.floor(minutesFromMidnight / 60); + const m = minutesFromMidnight % 60; + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; +} + +// Formats a Date object to YYYY-MM-DD using local time (prevents UTC timezone shifts). +export function toLocalDateString(date: Date): string { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} diff --git a/client/src/utils/toast.ts b/client/src/utils/toast.ts new file mode 100644 index 0000000000..3e83f50b7b --- /dev/null +++ b/client/src/utils/toast.ts @@ -0,0 +1,69 @@ +import toast from 'react-hot-toast'; + +const successStyle = { + background: '#059669', + color: '#fff', + borderRadius: '12px', + padding: '12px 16px', + fontSize: '14px', + fontWeight: '500' as const, +}; + +const errorStyle = { + background: '#DC2626', + color: '#fff', + borderRadius: '12px', + padding: '12px 16px', + fontSize: '14px', + fontWeight: '500' as const, +}; + +const infoStyle = { + background: '#000000', + color: '#fff', + borderRadius: '12px', + padding: '12px 16px', + fontSize: '14px', + fontWeight: '500' as const, +}; + +export const showSuccess = (message: string) => { + toast.success(message, { + style: successStyle, + iconTheme: { primary: '#fff', secondary: '#059669' }, + duration: 3000, + }); +}; + +export const showError = (message: string) => { + toast.error(message, { + style: errorStyle, + iconTheme: { primary: '#fff', secondary: '#DC2626' }, + duration: 4000, + }); +}; + +export const showInfo = (message: string) => { + toast(message, { + style: infoStyle, + iconTheme: { primary: '#fff', secondary: '#000000' }, + duration: 3000, + }); +}; + +export const extractErrorMessage = ( + error: unknown, + fallback = 'An unexpected error occurred' +): string => { + if (error && typeof error === 'object') { + const err = error as Record; + const response = err.response as Record | undefined; + const data = response?.data as Record | undefined; + if (data) { + if (typeof data.message === 'string') return data.message; + if (typeof data.error === 'string') return data.error; + } + if (typeof err.message === 'string') return err.message; + } + return fallback; +}; diff --git a/client/src/utils/venueDraft.ts b/client/src/utils/venueDraft.ts new file mode 100644 index 0000000000..fcf395d76a --- /dev/null +++ b/client/src/utils/venueDraft.ts @@ -0,0 +1,45 @@ +import type { AddVenueFormValues } from '@/types/venue.types'; + +// ─── Typed Draft Session ──────────────────────────────────────────────────── + +/** + * Shape stored in sessionStorage for an in-progress venue wizard. + * Cleared on submit or logout. + */ +export interface DraftSession { + venueId: string; // MongoDB _id of the Draft venue document + step: number; // which step to resume on: 0 | 1 | 2 + formValues: AddVenueFormValues; +} + +const getSessionKey = (userId: string): string => `venue_draft_session_${userId}`; + +export const saveDraftSession = (userId: string, data: DraftSession): void => { + sessionStorage.setItem(getSessionKey(userId), JSON.stringify(data)); +}; + +export const loadDraftSession = (userId: string): DraftSession | null => { + const raw = sessionStorage.getItem(getSessionKey(userId)); + return raw ? (JSON.parse(raw) as DraftSession) : null; +}; + +export const clearDraftSession = (userId: string): void => { + sessionStorage.removeItem(getSessionKey(userId)); +}; + +// ─── Legacy helpers (kept for AuthContext.logout compatibility) ───────────── + +const getDraftKey = (userId: string): string => `venue_draft_${userId}`; + +/** @deprecated use saveDraftSession */ +export const saveDraft = (userId: string, values: unknown): void => + sessionStorage.setItem(getDraftKey(userId), JSON.stringify(values)); + +/** @deprecated use loadDraftSession */ +export const loadDraft = (userId: string): T | null => { + const raw = sessionStorage.getItem(getDraftKey(userId)); + return raw ? (JSON.parse(raw) as T) : null; +}; + +/** @deprecated use clearDraftSession */ +export const clearDraft = (userId: string): void => sessionStorage.removeItem(getDraftKey(userId)); diff --git a/client/src/utils/venueFormMapper.ts b/client/src/utils/venueFormMapper.ts new file mode 100644 index 0000000000..5be5984ede --- /dev/null +++ b/client/src/utils/venueFormMapper.ts @@ -0,0 +1,154 @@ +import type { AddVenueFormValues } from '@/types/venue.types'; +import type { VenueDetail } from '@/types/venue.types'; +import type { IFixedPackage } from '@/types/venue.types'; + +export function mapFormToDTO(values: AddVenueFormValues, imageUrls: string[]) { + const base = { + name: values.VenueName, + description: values.VenueDescription, + address: values.fullAddress, + ...(values.googleMapsLink ? { googleMapsUrl: values.googleMapsLink } : {}), + ...(values.coordinates && { + coordinates: [Number(values.coordinates.lng), Number(values.coordinates.lat)], + }), + + venueType: values.venueType, + district: values.district, + state: values.state, + city: values.city, + pincode: values.pincode, + spaceAttributes: values.spaceAttributes, + seatingConfigurations: values.seatingConfigurations, + ...(values.maxCapacity ? { maxCapacity: Number(values.maxCapacity) } : {}), + + bookingType: values.bookingType, + workingDays: values.workingDays, + amenities: values.amenities, + + contact: { + name: values.contact.name, + phone: values.contact.phone, + ...(values.contact.email ? { email: values.contact.email } : {}), + }, + + cancellation: { + policy: values.cancellation.policy, + ...(values.cancellation.refundType ? { refundType: values.cancellation.refundType } : {}), + refundRules: (values.cancellation.refundRules ?? []) + .filter((r) => r.daysBefore !== '' && r.refundPercentage !== '') + .map((r) => ({ + daysBefore: Number(r.daysBefore), + refundPercentage: Number(r.refundPercentage), + })), + }, + + coverImage: imageUrls[0], + galleryImages: imageUrls.slice(1), + }; + + // Fixed booking + if (values.bookingType === 'fixedBooking') { + return { + ...base, + fixedPackages: (values.fixedPackages ?? []).map((p) => ({ + slotName: p.slotName, + startTime: p.startTime, + endTime: p.endTime, + price: Number(p.price), + })), + }; + } + + // Flexible booking + const pricingRules = (values.pricing.pricingRules ?? []) + .filter((r) => r.fromTime && r.toTime && r.price !== '' && r.price !== undefined) + .map((r) => ({ + fromTime: r.fromTime, + toTime: r.toTime, + price: Number(r.price), + })); + + const pricingType = values.pricingType; + const basePrice = + pricingType === 'fixedPricing' + ? Number(values.samePrice ?? 0) + : Number(values.pricing.basePrice ?? 0); + + return { + ...base, + workingHours: { + open: values.workingHours.open, + close: values.workingHours.close, + }, + flexibleBooking: { + slotDuration: + values.flexibleBooking.slotDuration === '' || + values.flexibleBooking.slotDuration === undefined + ? 60 + : Number(values.flexibleBooking.slotDuration), + bufferTime: + values.flexibleBooking.bufferTime === '' || values.flexibleBooking.bufferTime === undefined + ? 0 + : Number(values.flexibleBooking.bufferTime), + }, + pricing: { + pricingType, + basePrice, + pricingRules: pricingType === 'timeBasedPricing' ? pricingRules : [], + }, + blockedTimes: (values.blockedTimes ?? []).filter((b) => b.fromTime && b.toTime), + }; +} + +export function mapVenueToForm(venue: VenueDetail): AddVenueFormValues { + return { + VenueName: venue.name, + VenueDescription: venue.description, + venueType: venue.venueType, + district: venue.district, + state: venue.state || 'Kerala', + city: venue.city, + pincode: venue.pincode, + fullAddress: venue.address, + googleMapsLink: venue.googleMapsUrl || '', + coordinates: venue.location?.coordinates + ? { lat: venue.location.coordinates[1], lng: venue.location.coordinates[0] } + : null, + spaceAttributes: venue.spaceAttributes || [], + seatingConfigurations: venue.seatingConfigurations || [], + maxCapacity: venue.maxCapacity?.toString() || '', + bookingType: venue.bookingType, + workingDays: venue.workingDays || [], + fixedPackages: + venue.bookingType === 'fixedBooking' && venue.fixedPackages?.length + ? venue.fixedPackages.map((p: IFixedPackage) => ({ + slotName: p.slotName, + startTime: p.startTime, + endTime: p.endTime, + price: p.price, + })) + : [{ slotName: '', startTime: '', endTime: '', price: 0 }], + workingHours: venue.workingHours || { open: '', close: '' }, + flexibleBooking: venue.flexibleBooking || { slotDuration: '', bufferTime: '' }, + pricing: venue.pricing || { pricingType: '', basePrice: 0, pricingRules: [] }, + pricingType: venue.pricing?.pricingType || '', + samePrice: venue.pricing?.basePrice || 0, + blockedTimes: venue.blockedTimes || [{ fromTime: '', toTime: '', reason: '' }], + amenities: venue.amenities || [], + venuePhotos: [], + existingImages: { + coverImage: venue.coverImage, + galleryImages: venue.galleryImages || [], + }, + contact: { + name: venue.contact?.name || '', + phone: venue.contact?.phone || '', + email: venue.contact?.email || undefined, + }, + cancellation: { + policy: venue.cancellation?.policy || '', + refundType: venue.cancellation?.refundType || '', + refundRules: venue.cancellation?.refundRules || [{ daysBefore: '', refundPercentage: '' }], + }, + }; +} diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json new file mode 100644 index 0000000000..12edadabd1 --- /dev/null +++ b/client/tsconfig.app.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@components/*": ["./src/components/*"], + "@context/*": ["./src/context/*"], + "@services/*": ["./src/services/*"], + "@types/*": ["./src/types/*"], + "@utils/*": ["./src/utils/*"], + "@config/*": ["./src/config/*"], + "@hooks/*": ["./src/hooks/*"] + }, + "ignoreDeprecations": "6.0", + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/tests"] +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000000..84c8a23a86 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], +} diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json new file mode 100644 index 0000000000..d3c52ea64c --- /dev/null +++ b/client/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000000..0b814fed1b --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; + +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + ], + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src/"), + "@components": path.resolve(__dirname, "./src/components"), + "@context": path.resolve(__dirname, "./src/context"), + "@services": path.resolve(__dirname, "./src/services"), + "@types": path.resolve(__dirname, "./src/types"), + "@utils": path.resolve(__dirname, "./src/utils"), + "@config": path.resolve(__dirname, "./src/config"), + "@hooks": path.resolve(__dirname, "./src/hooks"), + }, + }, +}); \ No newline at end of file diff --git a/client/vitest.config.ts b/client/vitest.config.ts new file mode 100644 index 0000000000..ca3cb6bbf6 --- /dev/null +++ b/client/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import path from 'path'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/tests/setup.ts'], + include: ['src/tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src/'), + '@components': path.resolve(__dirname, './src/components'), + '@context': path.resolve(__dirname, './src/context'), + '@services': path.resolve(__dirname, './src/services'), + '@types': path.resolve(__dirname, './src/types'), + '@utils': path.resolve(__dirname, './src/utils'), + '@config': path.resolve(__dirname, './src/config'), + '@hooks': path.resolve(__dirname, './src/hooks'), + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..0d1f3dbe86 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +# ============================================================================= +# BookMyVenue — Docker Compose (local dev/testing only) +# +# This file is for local development and smoke-testing the containerised +# server. It runs the server service in isolation — no MongoDB, no frontend. +# +# Production deployments should use a proper container registry + orchestrator +# (ECS, Cloud Run, Kubernetes, etc.) and inject secrets via the platform's +# native secrets manager, not an .env file. +# ============================================================================= + +services: + server: + build: + context: ./server + dockerfile: Dockerfile + ports: + - "3000:3003" + env_file: ./server/.env + volumes: + # Persist Winston daily-rotate-file logs outside the container + - ./server/logs:/app/logs diff --git a/docs/api-overview.md b/docs/api-overview.md new file mode 100644 index 0000000000..abfaf49a67 --- /dev/null +++ b/docs/api-overview.md @@ -0,0 +1,137 @@ +# API Overview + +## Base URL +All API routes are mounted under `/api/v1`. Example: +``` +http://localhost:3000/api/v1/health +``` + +## Health Check +``` +GET /api/v1/health +Response: { "success": true, "message": "Service is healthy" } +``` + +## Authentication Flow +The API uses JWT-based authentication with access + refresh token pattern: + +1. **Register**: `POST /api/v1/auth/register` — Create account (username, email, password) +2. **Login**: `POST /api/v1/auth/login` — Returns access token in response body, refresh token in HTTP-only cookie +3. **Access**: Include access token in `Authorization: Bearer ` header +4. **Refresh**: `POST /api/v1/auth/refresh` — Uses refresh token cookie to issue new access token +5. **Logout**: `POST /api/v1/auth/logout` — Revokes current session + +Additional auth endpoints: +- `POST /api/v1/auth/forgot-password` — Sends reset email (rate-limited: 5/15min) +- `POST /api/v1/auth/reset-password` — Resets password with token +- `PATCH /api/v1/auth/change-password` — Change password (authenticated) +- `GET /api/v1/auth/sessions` — List active sessions +- `POST /api/v1/auth/sessions/logout-others` — Sign out other sessions +- `DELETE /api/v1/auth/sessions/:sessionId` — Revoke specific session + +Rate limiting: Login is limited to 10 attempts per 15 minutes per IP. + +## Response Format + +### Success +```json +{ + "success": true, + "message": "Operation completed successfully", + "data": { ... } +} +``` + +### Error +```json +{ + "success": false, + "message": "Error description", + "error": "Detailed error message (optional)" +} +``` + +### Paginated +```json +{ + "success": true, + "message": "Items retrieved successfully", + "data": { + "items": [ ... ], + "pagination": { + "total": 87, + "page": 2, + "limit": 10, + "skip": 10, + "totalPages": 9, + "hasNext": true, + "hasPrev": true + } + } +} +``` + +Pagination query parameters: +| Parameter | Type | Default | Description | +|---|---|---|---| +| `page` | number | `1` | 1-based page number | +| `limit` | number | `20` | Items per page (clamped between min/max) | +| `skip` | number | — | Override skip directly (ignores page) | +| `sort` | string | `-createdAt` | Comma-separated fields; prefix `-` for descending | + +### Status Codes Used by ResponseUtil +| Method | HTTP Status | +|---|---| +| `success()` | 200 | +| `created()` | 201 | +| `error()` | 500 | +| `notFound()` | 404 | +| `badRequest()` | 400 | +| `unauthorized()` | 401 | +| `forbidden()` | 403 | +| `conflict()` | 409 | +| `rateLimitExceeded()` | 429 | +| `validationError()` | 422 | +| `internalServerError()` | 500 | +| `serverUnavailable()` | 503 | + +## Request Validation +All request data is validated using Zod schemas via the validation middleware: +- **Body**: `validateBody(schema)` — Validates `req.body` +- **Params**: `validateParams(schema)` — Validates route parameters +- **Query**: Validation available via middleware pattern + +Validated data is available at `req.validated.body`, `req.validated.params`, or `req.validated.query`. Controllers must never access raw `req.body` directly. + +## Webhook Handling +Webhooks (Razorpay) are mounted at `/api/v1/webhook` **before** the JSON body parser in the middleware stack. This ensures the raw request body is available as a Buffer for HMAC-SHA256 signature verification. + +## Idempotency +Certain endpoints (payment processing, booking creation, venue state changes) support idempotency via the `Idempotency-Key` header: +- Pass a unique key in the request header +- If the same key is reused within a time window, the server returns the original response instead of processing again +- Implemented via `idempotency.middleware.ts` using MongoDB storage + +## Swagger API Documentation +Interactive API documentation is available at `http://localhost:3000/api/v1/swagger` when the server is running. It uses swagger-jsdoc with inline JSDoc annotations in controller/route files. Access is protected by basic authentication (credentials from `SWAGGER_USER`/`SWAGGER_PASS` env vars). + +## API Route Map +All routes under `/api/v1/`: + +| Prefix | Module | Auth Required | +|---|---|---| +| `/auth` | Authentication | Varies by endpoint | +| `/user` | User management | Yes | +| `/venues` | Venue CRUD | Varies | +| `/bookings` | Booking management | Yes | +| `/availability` | Availability schedules | Varies | +| `/owner` | Owner operations | Yes (owner+) | +| `/rbac` | RBAC management | Yes (admin+) | +| `/role` | Role management | Yes (admin+) | +| `/reviews` | Reviews & ratings | Varies | +| `/moderation` | Moderation | Yes (admin+) | +| `/geo` | Geocoding/search | Yes | +| `/wishlist` | User wishlists | Yes | +| `/webhook` | External webhooks | None (HMAC verified) | +| `/swagger` | API docs | Basic auth | +| `/health` | Health check | None | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000000..1cba9f3b0c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,145 @@ +# BookMyVenue Architecture + +## Overview +BookMyVenue is an open-source venue booking platform built as a pnpm monorepo with three workspaces: `server` (Node.js/Express API), `client` (user-facing React app), and `admin` (owner/admin dashboard). + +## Tech Stack +| Component | Technology | +|---|---| +| Backend | Node.js, Express 5, TypeScript ~5.9 | +| Database | MongoDB via Mongoose 9 | +| Frontend (Client) | React 19, Vite 8, TailwindCSS 4, TypeScript ~6.0 | +| Frontend (Admin) | React 19, Vite 8, Zustand 5, TanStack React Table, Recharts | +| Package Manager | pnpm 10.16.1 (workspaces) | +| API Docs | Swagger/OpenAPI via swagger-jsdoc | +| Payment | Razorpay | +| Email | Resend | +| Media | Cloudinary | + +## Monorepo Structure +``` +BookMyVenue/ +├── server/ # Express API backend +│ └── src/ +│ ├── modules/ # Feature modules (auth, venue, booking, etc.) +│ ├── middlewares/ # Express middlewares +│ ├── models/ # Shared Mongoose models +│ ├── services/ # Shared business logic +│ ├── workers/ # Background job processors +│ ├── configs/ # Configuration files +│ ├── constants/ # App constants (permissions, etc.) +│ ├── utils/ # Utilities (logger, response, errors) +│ └── types/ # TypeScript declarations +├── client/ # User-facing React app +│ └── src/ +│ ├── pages/ # Page components +│ ├── components/ # Reusable UI components +│ ├── hooks/ # Custom React hooks +│ ├── services/ # API service layer (Axios) +│ ├── context/ # React Context state +│ ├── utils/ # Utilities +│ └── constants/ # App constants +└── admin/ # Admin dashboard React app + └── src/ + ├── pages/ + ├── components/ + ├── hooks/ + ├── services/ + ├── store/ # Zustand stores + ├── utils/ + └── constants/ +``` + +## Module Architecture (Server) +Each feature module follows a consistent pattern: + +``` +modules// +├── controller.ts → Express route handlers (reads req.validated) +├── service.ts → Business logic layer +├── repository.ts → Database queries (Mongoose) +├── router.ts → Route definitions with middleware +├── validator.ts → Zod schemas for request validation +├── types.ts → Module-specific TypeScript types +└── models/ → Mongoose schemas (optional, some in root models/) +``` + +Data flows: **Router → Middleware (auth, validation, RBAC, pagination) → Controller → Service → Repository → MongoDB** + +Controllers always access validated data via `req.validated` (not raw `req.body`), populated by the validation middleware. + +## Server Modules (14 total) +| Module | Description | Files | +|---|---|---| +| `auth` | Login, register, JWT tokens, password reset, session management | 5 | +| `venue` | CRUD, draft management, venue submission workflow | 10 | +| `booking` | Booking CRUD, Razorpay payment integration, booking workflow | 8 | +| `availability` | Venue availability schedules | 5 | +| `user` | User profile management, admin user management | 8 | +| `owner` | Owner-specific operations: analytics, block dates, offline bookings, reviews, settings, venue management | 6 | +| `rbac` | Role-based access control | 3 | +| `role` | Role/permission management | 5 | +| `review` | Venue reviews, ratings, moderation | 7 | +| `moderation` | Content moderation: banned users, activity logs, summary dashboard | 13 (2 sub-routers) | +| `geo` | Geocoding/search via OpenStreetMap Nominatim | 3 | +| `wishlist` | User wishlists, toggle, sync, status | 7 | +| `webhook` | Razorpay webhook handling (HMAC verification) | 3 | +| `swagger` | OpenAPI/Swagger documentation UI | 2 | + +## Middleware Stack +Order in `server/src/app.ts`: +1. `helmet()` — Security headers +2. Rate limiter — 100 requests/15 min (global) +3. Webhook router (`/api/v1/webhook`) — Mounted BEFORE body parsing (raw body needed for HMAC) +4. `express.json()`, `express.urlencoded()` — Body parsing (10kb limit) +5. CORS, cookie-parser, compression — Cross-origin, cookies, gzip/brotli +6. Cache-control headers — `no-store` on all API responses +7. Request logger — Winston HTTP logging +8. Main router (`/api/v1`) — All module routes +9. 404 handler — Route/method not found +10. Global error handler — Unhandled exceptions + +## Available Middlewares +- **authMiddleware** (`auth.middleware.ts`) — Validates JWT, populates `req.user` +- **validationMiddleware** (`validation.middleware.ts`) — Validates body/params/query against Zod schemas, stores in `req.validated` +- **rbacMiddleware** (`rbac.middleware.ts`) — Checks user permissions/roles, supports `requirePermission()` and `requireRole()` +- **paginationMiddleware** (`pagination.middleware.ts`) — Parses `page`, `limit`, `sort`, `skip` query params +- **ownerTenantMiddleware** (`ownerTenant.middleware.ts`) — Validates owner access to venue-scoped resources +- **idempotencyMiddleware** (`idempotency.middleware.ts`) — Ensures idempotent payment/booking operations via `Idempotency-Key` header + +## RBAC System +Roles are stored in MongoDB with a hierarchy: +``` +superAdmin (rank 1) → admin (rank 2) → owner (rank 3) → user (rank 4) +``` +- Permissions follow `action:entity` pattern (e.g., `create:venues`, `read:bookings`) +- Role hierarchy is resolved via `$graphLookup` at runtime +- RBAC data must be seeded after fresh DB: `pnpm script seed:rbac` +- Server verifies seed at startup via `verifyRbacSeed()` and exits if missing + +## API Response Format +All responses use `ResponseUtil`: +- **Success**: `{ success: true, message: "...", data?: {...} }` +- **Error**: `{ success: false, message: "...", error?: "..." }` +- **Paginated**: `{ success: true, message: "...", data: { items: [...], pagination: { total, page, limit, skip, totalPages, hasNext, hasPrev } } }` + +`ResponseUtil` methods: `success`, `paginated`, `error`, `created`, `notFound`, `badRequest`, `unauthorized`, `forbidden`, `conflict`, `rateLimitExceeded`, `validationError`, `internalServerError`, `serverUnavailable` + +## Background Workers +3 workers start automatically with the server in `server.ts`: +1. **Email Worker** (`workers/email.worker.ts`) — Processes queued emails from `email-task` MongoDB collection +2. **Ban Expiry Worker** (`workers/banExpiry.worker.ts`) — Automatically lifts expired bans +3. **Venue Edit Deadline Worker** (`workers/venueEditDeadline.worker.ts`) — Manages venue edit deadlines + +## State Management +- **Server**: Stateless (JWT-based auth), MongoDB as single source of truth +- **Client**: React Context + TanStack React Query for server state +- **Admin**: Zustand stores for local state + TanStack React Query for server state + +## Key Design Patterns +- **Draft/Submit workflow**: Venue creation uses a two-phase flow (incremental draft → full submission) +- **Validation**: Zod schemas validated by middleware; controllers never access raw `req.body` +- **Pagination**: Standardized via `paginationMiddleware` with configurable defaults per route +- **Idempotency**: Key-based idempotency for payment/booking endpoints stored in MongoDB +- **Error handling**: Controllers wrap service calls in try/catch with `handleError()` utility; global handler catches unhandled errors +- **Webhook handling**: Raw body preserved via early route mounting for Razorpay HMAC verification diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000000..ce97d84a03 --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,155 @@ +# Server Module Reference + +A catalog of all 14 feature modules in `server/src/modules/`. + +--- + +**Module: auth** (5 files) +*Files: auth.controller.ts, auth.repository.ts, auth.router.ts, auth.service.ts, auth.validator.ts* +Handles user authentication and session management: +- Register with username/email/password +- Login with email or username (rate-limited: 10/15min) +- JWT access + refresh token flow +- Token refresh using HTTP-only cookie +- Logout with session revocation +- Forgot/reset password flow (rate-limited: 5/15min) +- Change password (authenticated) +- Session listing and management (revoke single, revoke others) + +--- + +**Module: availability** (5 files) +*Files: availability.controller.ts, availability.repository.ts, availability.router.ts, availability.validator.ts, availability.workflow.ts* +Manages venue availability schedules: +- Recurring weekly availability windows +- Date-specific overrides +- Blocked dates for maintenance/events + +--- + +**Module: booking** (8 files) +*Files: booking.controller.ts, booking.repository.ts, booking.router.ts, booking.service.ts, booking.types.ts, booking.validator.ts, booking.workflow.ts, lock.types.ts* +Complete booking lifecycle: +- Create, list, view, cancel bookings +- Razorpay payment integration +- Booking lock mechanism to prevent double-booking +- Availability validation during booking +- Offline booking creation (owner feature) + +--- + +**Module: geo** (3 files) +*Files: geo.router.ts, geo.service.ts, geo.types.ts* +Geocoding and location search: +- Search places using OpenStreetMap Nominatim +- Returns coordinates, city, district, postcode +- Restricted to Indian locations + +--- + +**Module: moderation** (13 files across 2 sub-routers) +*Files: bannedUser.controller.ts, bannedUser.model.ts, bannedUser.repository.ts, bannedUser.router.ts, bannedUser.service.ts, bannedUser.types.ts, bannedUser.validator.ts, moderation.repository.ts, moderation.router.ts, moderation.service.ts, moderation.types.ts, moderationActivity.model.ts, moderationActivity.service.ts* +Content moderation system: +- Dashboard summary (pending reviews, suspended venues, banned users) +- Ban/unban users with scope (global/venue) and optional expiry +- Moderation activity log (superAdmin only) +- Ban management: list user bans, lift individual bans, lift all bans + +--- + +**Module: owner** (6 files) +*Files: owner.controller.ts, owner.repository.ts, owner.router.ts, owner.service.ts, owner.validator.ts, owner.workflow.ts* +Owner/venue-tenant specific operations: +- Venue analytics dashboard +- Block/unblock dates +- Venue booking management +- Availability calendar +- Offline booking creation +- Review management (reply, report) +- Venue settings management +- Venue state management (activate, request inactivity, block/unblock bookings) +- Delete venue request flow +- Owner tenant verification middleware + +--- + +**Module: rbac** (3 files) +*Files: rbac.controller.ts, rbac.router.ts, rbac.service.ts* +Role-based access control: +- List admins (superAdmin) +- Promote user to admin +- Demote admin to user +- RBAC seeding for new databases + +--- + +**Module: review** (7 files) +*Files: review.controller.ts, review.model.ts, review.ownership.ts, review.repository.ts, review.router.ts, review.service.ts, review.types.ts* +Venue review and rating system: +- Submit, update, delete reviews +- Get venue reviews (public, paginated) +- Get user's own rating for a venue +- Review ownership verification +- Flagged review management (admin) +- Review moderation (admin: flag, remove, restore) + +--- + +**Module: role** (5 files) +*Files: role.controller.ts, role.repository.ts, role.router.ts, role.service.ts, role.validator.ts* +Role and permission management: +- CRUD for roles +- Assign permissions to roles +- Role hierarchy management + +--- + +**Module: swagger** (2 files) +*Files: swagger.config.ts, swagger.router.ts* +OpenAPI/Swagger documentation: +- Generates API docs from JSDoc annotations +- Serves Swagger UI at `/api/v1/swagger` +- Protected by basic authentication + +--- + +**Module: user** (8 files) +*Files: user.controller.ts, user.models.ts, user.repository.ts, user.router.ts, user.service.ts, user.types.ts, user.validator.ts, user.workflow.ts* +User profile and account management: +- View and update profile +- Admin user listing (paginated) +- Toggle user active/ban status +- Admin management (list owners) +- Account deletion workflow + +--- + +**Module: venue** (10 files) +*Files: venue.controller.ts, venue.model.ts, venue.ownership.ts, venue.repository.ts, venue.router.ts, venue.service.ts, venue.types.ts, venue.validator.ts, venue.workflow.ts, venueDraft.model.ts* +Complete venue management: +- CRUD operations for venues +- Draft/submit workflow (incremental draft saves → full submission) +- Venue search and listing with filters +- Featured venue management +- Venue approval/rejection (admin) +- Venue activation/deactivation +- Venue ownership verification +- Image upload signature generation (Cloudinary) + +--- + +**Module: webhook** (3 files) +*Files: webhook.controller.ts, webhook.router.ts, webhook.types.ts* +External webhook handling: +- Razorpay payment webhook with HMAC-SHA256 verification +- Raw body required — mounted before `express.json()` in app.ts + +--- + +**Module: wishlist** (7 files) +*Files: wishlist.controller.ts, wishlist.model.ts, wishlist.repository.ts, wishlist.router.ts, wishlist.service.ts, wishlist.types.ts, wishlist.validator.ts* +User wishlist functionality: +- Toggle venue in wishlist +- Get user's wishlist (paginated) +- Check wishlist status for multiple venues +- Sync guest's local wishlist to account after login diff --git a/favicon/apple-touch-icon.png b/favicon/apple-touch-icon.png new file mode 100644 index 0000000000..2000537371 Binary files /dev/null and b/favicon/apple-touch-icon.png differ diff --git a/favicon/favicon-96x96.png b/favicon/favicon-96x96.png new file mode 100644 index 0000000000..9e83e2a631 Binary files /dev/null and b/favicon/favicon-96x96.png differ diff --git a/favicon/favicon.ico b/favicon/favicon.ico new file mode 100644 index 0000000000..ea259ee47b Binary files /dev/null and b/favicon/favicon.ico differ diff --git a/favicon/favicon.svg b/favicon/favicon.svg new file mode 100644 index 0000000000..cc652d1204 --- /dev/null +++ b/favicon/favicon.svg @@ -0,0 +1 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/favicon/site.webmanifest b/favicon/site.webmanifest new file mode 100644 index 0000000000..b55d1951c9 --- /dev/null +++ b/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "BookMyVenue", + "short_name": "BMV", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/favicon/web-app-manifest-192x192.png b/favicon/web-app-manifest-192x192.png new file mode 100644 index 0000000000..86806ded66 Binary files /dev/null and b/favicon/web-app-manifest-192x192.png differ diff --git a/favicon/web-app-manifest-512x512.png b/favicon/web-app-manifest-512x512.png new file mode 100644 index 0000000000..25386ddb5e Binary files /dev/null and b/favicon/web-app-manifest-512x512.png differ diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs new file mode 100644 index 0000000000..dc5f915370 --- /dev/null +++ b/lint-staged.config.mjs @@ -0,0 +1,32 @@ +// @ts-check + +/** + * Lint-staged configuration for BookMyVenue monorepo. + * + * Each sub-project has its own pattern and commands: + * - ESLint runs only on the matched (staged) files + * - TypeScript check runs on the full project when any TS file changes + * - Unit test suite runs after typecheck + */ +export default { + // Client project — lint changed files only, full tsc, and unit tests + "client/src/**/*.{ts,tsx}": [ + (files) => `pnpm --filter client exec eslint --no-warn-ignored ${files.join(" ")}`, + () => "pnpm --filter client exec tsc --noEmit", + () => "pnpm --filter client test", + ], + + // Admin project — lint changed files only, full tsc, and unit tests + "admin/src/**/*.{ts,tsx}": [ + (files) => `pnpm --filter admin exec eslint --no-warn-ignored ${files.join(" ")}`, + () => "pnpm --filter admin exec tsc --noEmit", + () => "pnpm --filter admin test", + ], + + // Server project — lint changed files only, full tsc, and unit tests + "server/src/**/*.ts": [ + (files) => `pnpm --filter server exec eslint --no-warn-ignored ${files.join(" ")}`, + () => "pnpm --filter server exec tsc --noEmit", + () => "pnpm --filter server test", + ], +}; diff --git a/logo small.png b/logo small.png new file mode 100644 index 0000000000..be013a694b Binary files /dev/null and b/logo small.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000000..f8abc0d4bf --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "bookmyvenue", + "version": "1.0.0", + "description": "BookMyVenue Monorepo", + "private": true, + "workspaces": [ + "server", + "client", + "admin" + ], + "scripts": { + "install:all": "pnpm install", + "build": "pnpm -r build", + "dev": "pnpm -r dev", + "format": "pnpm -r format", + "lint": "pnpm -r lint", + "prepare": "husky" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.16.1", + "devDependencies": { + "husky": "^9.1.7", + "lint-staged": "^17.0.7" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000000..5f06931573 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,9860 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^17.0.7 + version: 17.0.7 + + admin: + dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.17 + version: 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': + specifier: ^1.3.0 + version: 1.3.0(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-tooltip': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-query': + specifier: ^5.101.0 + version: 5.101.0(react@19.2.7) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + axios: + specifier: ^1.17.0 + version: 1.17.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + lucide-react: + specifier: ^1.21.0 + version: 1.21.0(react@19.2.7) + prettier: + specifier: 3.8.3 + version: 3.8.3 + radix-ui: + specifier: ^1.6.0 + version: 1.6.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.2.6 + version: 19.2.7 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.16)(react@19.2.7) + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + react-hot-toast: + specifier: ^2.6.0 + version: 2.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-router: + specifier: ^7.16.0 + version: 7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + recharts: + specifier: ^3.9.0 + version: 3.9.0(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.16)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + devDependencies: + '@babel/core': + specifier: ^7.29.0 + version: 7.29.7 + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.7.2 + version: 4.7.2(eslint@10.4.1(jiti@2.7.0)) + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.1(jiti@2.7.0)) + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@testing-library/jest-dom': + specifier: ^7.0.0 + version: 7.0.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/babel__core': + specifier: ^7.20.5 + version: 7.20.5 + '@types/node': + specifier: ^24.12.3 + version: 24.12.4 + '@types/react': + specifier: ^19.2.14 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.16) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + eslint: + specifier: ^10.3.0 + version: 10.4.1(jiti@2.7.0) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.4.1(jiti@2.7.0)) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.4.1(jiti@2.7.0)) + globals: + specifier: ^17.6.0 + version: 17.6.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@1.8.0) + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + vite: + specifier: ^8.0.12 + version: 8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.12.4)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + + client: + dependencies: + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@tanstack/react-query': + specifier: ^5.101.0 + version: 5.101.0(react@19.2.7) + axios: + specifier: ^1.17.0 + version: 1.17.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + formik: + specifier: ^2.4.9 + version: 2.4.9(@types/react@19.2.16)(react@19.2.7) + framer-motion: + specifier: ^12.42.2 + version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + leaflet: + specifier: ^1.9.4 + version: 1.9.4 + lucide-react: + specifier: ^1.18.0 + version: 1.21.0(react@19.2.7) + radix-ui: + specifier: ^1.5.0 + version: 1.6.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.2.6 + version: 19.2.7 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.16)(react@19.2.7) + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + react-easy-crop: + specifier: ^6.2.0 + version: 6.2.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-hot-toast: + specifier: ^2.6.0 + version: 2.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-icons: + specifier: ^5.6.0 + version: 5.6.0(react@19.2.7) + react-router: + specifier: ^7.16.0 + version: 7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-router-dom: + specifier: ^7.17.0 + version: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + shadcn: + specifier: ^4.11.0 + version: 4.11.0(typescript@6.0.3) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + yup: + specifier: ^1.7.1 + version: 1.7.1 + devDependencies: + '@babel/core': + specifier: ^7.29.0 + version: 7.29.7 + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.7.2 + version: 4.7.2(eslint@10.4.1(jiti@2.7.0)) + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.1(jiti@2.7.0)) + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@testing-library/jest-dom': + specifier: ^7.0.0 + version: 7.0.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/babel__core': + specifier: ^7.20.5 + version: 7.20.5 + '@types/leaflet': + specifier: ^1.9.21 + version: 1.9.21 + '@types/node': + specifier: ^24.12.3 + version: 24.12.4 + '@types/react': + specifier: ^19.2.14 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.16) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + eslint: + specifier: ^10.3.0 + version: 10.4.1(jiti@2.7.0) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.4.1(jiti@2.7.0)) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.4.1(jiti@2.7.0)) + globals: + specifier: ^17.6.0 + version: 17.6.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@1.8.0) + prettier: + specifier: 3.8.3 + version: 3.8.3 + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + vite: + specifier: ^8.0.16 + version: 8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.12.4)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + + server: + dependencies: + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 + brotli: + specifier: ^1.3.3 + version: 1.3.3 + cloudinary: + specifier: ^2.10.0 + version: 2.10.0 + compression: + specifier: ^1.8.1 + version: 1.8.1 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 + cors: + specifier: ^2.8.6 + version: 2.8.6 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + eslint: + specifier: ^10.7.0 + version: 10.7.0(jiti@2.7.0) + express: + specifier: ^5.2.1 + version: 5.2.1 + express-rate-limit: + specifier: ^8.6.0 + version: 8.6.0(express@5.2.1) + express-validator: + specifier: ^7.3.2 + version: 7.3.2 + helmet: + specifier: ^8.3.0 + version: 8.3.0 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + mongoose: + specifier: ^9.8.0 + version: 9.8.0 + razorpay: + specifier: ^2.9.8 + version: 2.9.8 + resend: + specifier: ^6.18.0 + version: 6.18.0 + swagger-jsdoc: + specifier: ^6.3.0 + version: 6.3.0(openapi-types@12.1.3) + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@5.2.1) + winston: + specifier: ^3.19.0 + version: 3.19.0 + winston-daily-rotate-file: + specifier: ^5.0.0 + version: 5.0.0(winston@3.19.0) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.7.2 + version: 4.7.2(eslint@10.7.0(jiti@2.7.0)) + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.7.0(jiti@2.7.0)) + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@5.0.6) + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/node': + specifier: ^22.13.0 + version: 22.19.19 + '@types/supertest': + specifier: ^7.2.1 + version: 7.2.1 + '@types/swagger-jsdoc': + specifier: ^6.0.4 + version: 6.0.4 + '@types/swagger-ui-express': + specifier: ^4.1.8 + version: 4.1.8 + '@vitest/coverage-v8': + specifier: ^4.0.0 + version: 4.1.10(vitest@4.1.10) + globals: + specifier: ^17.7.0 + version: 17.7.0 + mongodb-memory-server: + specifier: ^11.2.0 + version: 11.2.0 + prettier: + specifier: ^3.9.6 + version: 3.9.6 + supertest: + specifier: ^7.2.2 + version: 7.2.2 + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.65.0 + version: 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + vitest: + specifier: ^4.0.0 + version: 4.1.10(@types/node@22.19.19)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@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@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@dotenvx/dotenvx@1.74.2': + resolution: {integrity: sha512-UucMF9L95sVr/feRojWB/NIc5R+CzoiiXq7rd7sWppYA7j6CIH5bu5bQCuoJ0HJxQARR0Ecg4bYot3ilo100lw==} + hasBin: true + + '@ecies/ciphers@0.2.6': + resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} + engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.7.2': + resolution: {integrity: sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@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/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fontsource-variable/inter@5.2.8': + resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@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/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mongodb-js/saslprep@1.4.11': + resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@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'} + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@radix-ui/number@1.1.2': + resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} + + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + + '@radix-ui/react-accessible-icon@1.1.10': + resolution: {integrity: sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==} + 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-accordion@1.2.14': + resolution: {integrity: sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==} + 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-alert-dialog@1.1.17': + resolution: {integrity: sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==} + 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-arrow@1.1.10': + resolution: {integrity: sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==} + 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-aspect-ratio@1.1.10': + resolution: {integrity: sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==} + 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-avatar@1.2.0': + resolution: {integrity: sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==} + 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-checkbox@1.3.5': + resolution: {integrity: sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==} + 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-collapsible@1.1.14': + resolution: {integrity: sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==} + 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-collection@1.1.10': + resolution: {integrity: sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==} + 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-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.1': + resolution: {integrity: sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==} + 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-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.17': + resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} + 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-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.13': + resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} + 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-dropdown-menu@2.1.18': + resolution: {integrity: sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==} + 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-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.10': + resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} + 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-form@0.1.10': + resolution: {integrity: sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==} + 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-hover-card@1.1.17': + resolution: {integrity: sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==} + 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-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.10': + resolution: {integrity: sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==} + 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-menu@2.1.18': + resolution: {integrity: sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==} + 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-menubar@1.1.18': + resolution: {integrity: sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==} + 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-navigation-menu@1.2.16': + resolution: {integrity: sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==} + 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-one-time-password-field@0.1.10': + resolution: {integrity: sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==} + 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-password-toggle-field@0.1.5': + resolution: {integrity: sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==} + 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-popover@1.1.17': + resolution: {integrity: sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==} + 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-popper@1.3.1': + resolution: {integrity: sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==} + 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-portal@1.1.12': + resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} + 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-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + 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-primitive@2.1.6': + resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} + 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-progress@1.1.10': + resolution: {integrity: sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==} + 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-radio-group@1.4.1': + resolution: {integrity: sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==} + 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-roving-focus@1.1.13': + resolution: {integrity: sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==} + 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-scroll-area@1.2.12': + resolution: {integrity: sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==} + 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-select@2.3.1': + resolution: {integrity: sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==} + 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-separator@1.1.10': + resolution: {integrity: sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==} + 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-slider@1.4.1': + resolution: {integrity: sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==} + 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.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.3.1': + resolution: {integrity: sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==} + 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-tabs@1.1.15': + resolution: {integrity: sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==} + 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-toast@1.2.17': + resolution: {integrity: sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==} + 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-toggle-group@1.1.13': + resolution: {integrity: sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==} + 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-toggle@1.1.12': + resolution: {integrity: sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==} + 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-toolbar@1.1.13': + resolution: {integrity: sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==} + 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-tooltip@1.2.10': + resolution: {integrity: sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + 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-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + 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-escape-keydown@1.1.2': + resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} + 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-is-hydrated@0.1.1': + resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + 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-previous@1.1.2': + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} + 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-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + 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-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.6': + resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==} + 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/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + + '@tanstack/react-query@5.101.0': + resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.0': + resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + + '@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/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hoist-non-react-statics@3.3.7': + resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} + peerDependencies: + '@types/react': '*' + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.16': + resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@7.2.1': + resolution: {integrity: sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==} + + '@types/swagger-jsdoc@6.0.4': + resolution: {integrity: sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==} + + '@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/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@types/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + + '@types/whatwg-url@13.0.0': + resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} + + '@typescript-eslint/eslint-plugin@8.60.1': + resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.60.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.60.1': + resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.60.1': + resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.60.1': + resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.60.1': + resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.60.1': + resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.60.1': + resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.60.1': + resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.60.1': + resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.60.1': + resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + 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@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + 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'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomically@1.7.0: + resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} + engines: {node: '>=10.12.0'} + + axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.6: + resolution: {integrity: sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.33: + resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bson@7.2.0: + resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} + engines: {node: '>=20.19.0'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + 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'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cloudinary@2.10.0: + resolution: {integrity: sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==} + engines: {node: '>=9'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + 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@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@6.2.0: + resolution: {integrity: sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==} + engines: {node: '>= 6'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + conf@10.2.0: + resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} + engines: {node: '>=12'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + 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'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + 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'} + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debounce-fn@4.0.0: + resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} + engines: {node: '>=10'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + 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@2.2.1: + resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==} + engines: {node: '>=0.10.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + eciesjs@0.4.18: + resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.367: + resolution: {integrity: sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + 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'} + + enhanced-resolve@5.22.2: + resolution: {integrity: sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==} + engines: {node: '>=10.13.0'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + 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-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.4.1: + resolution: {integrity: sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + eslint@10.7.0: + resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + 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'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + 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'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.6.0: + resolution: {integrity: sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express-validator@7.3.2: + resolution: {integrity: sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==} + engines: {node: '>= 8.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + 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==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-stream-rotator@0.6.1: + resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + + 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@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + 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'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + formik@2.4.9: + resolution: {integrity: sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==} + peerDependencies: + react: '>=16.8.0' + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + 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 + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + engines: {node: '>=18'} + + goober@2.1.19: + resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==} + peerDependencies: + csstype: ^3.0.10 + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + 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.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hono@4.12.26: + resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==} + engines: {node: '>=16.9.0'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + 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-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@7.0.3: + resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + 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 + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + kareem@3.3.0: + resolution: {integrity: sha512-kpSuLD3/7RenBnjnJdOHXCKC8dTd1JzeOiJhN0necWWci6cC+qX+VuwPnMVgb+a4+KNJSfgqahpnfWaeDXCimw==} + engines: {node: '>=18.0.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@17.0.7: + resolution: {integrity: sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==} + engines: {node: '>=22.22.1'} + hasBin: true + + listr2@10.2.1: + resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} + engines: {node: '>=22.13.0'} + + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.21.0: + resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + 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'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + 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'} + + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + mongodb-connection-string-url@7.0.1: + resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} + engines: {node: '>=20.19.0'} + + mongodb-memory-server-core@11.2.0: + resolution: {integrity: sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==} + engines: {node: '>=20.19.0'} + + mongodb-memory-server@11.2.0: + resolution: {integrity: sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==} + engines: {node: '>=20.19.0'} + + mongodb@7.2.0: + resolution: {integrity: sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.806.0 + '@mongodb-js/zstd': ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: '>=7.0.0 <7.1.0' + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + mongodb@7.5.0: + resolution: {integrity: sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.806.0 + '@mongodb-js/zstd': ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: ^7.2.0 + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + mongoose@9.8.0: + resolution: {integrity: sha512-PDGx3XACxrBQyWf4YT+5s1Xsx19x84UWGlRVIza4i9RG6qKjGcoG7odotT6uquR6YoaDMTZ6ZZc/jMXLrNPyyA==} + engines: {node: '>=20.19.0'} + + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + mpath@0.9.0: + resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==} + engines: {node: '>=4.0.0'} + + mquery@6.0.0: + resolution: {integrity: sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==} + engines: {node: '>=20.19.0'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + new-find-package-json@2.0.0: + resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==} + engines: {node: '>=12.22.0'} + + node-addon-api@8.8.0: + resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} + engines: {node: ^18 || ^20 || >= 21} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + normalize-wheel@1.0.1: + resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + 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'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + 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'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + + p-locate@4.1.0: + 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'} + + 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'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + + postal-mime@2.7.5: + resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==} + + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + engines: {node: '>=4'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix-ui@1.6.0: + resolution: {integrity: sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==} + 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 + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + razorpay@2.9.8: + resolution: {integrity: sha512-Uqv3KV7JAfpLlabGdg8Cw7e7ieGkK/DighFq6iGkNnz0Y+it6+/lFmPCTfkcptc2EagL2Og25VWUxc+Fgp/+nA==} + + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-easy-crop@6.2.0: + resolution: {integrity: sha512-8CEes7M8UkNoRPaz/krm70xMTbpar07crz/OrPSJwdGopV6lPcqdEmamDYTXgsqukb/1e2fbFd9/mROi8Qkp+A==} + peerDependencies: + react: '>=16.4.0' + react-dom: '>=16.4.0' + + react-fast-compare@2.0.4: + resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==} + + react-hot-toast@2.6.0: + resolution: {integrity: sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + + react-icons@5.6.0: + resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} + peerDependencies: + react: '*' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.17.0: + resolution: {integrity: sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.16.0: + resolution: {integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-router@7.17.0: + resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + + recharts@3.9.0: + resolution: {integrity: sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + + resend@6.18.0: + resolution: {integrity: sha512-EjxZ9AVzywJgOlUoIJe9ytBWVrfbUtJbjeoLnRSvpU1sv97Hh9DSwhw+k8kiujrG4Rg4bzTBsjlmwWWuoOxSug==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + 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==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + 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.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn@4.11.0: + resolution: {integrity: sha512-UV0cchFea9hO7poV1CuEP0wvmYjpAqcxCKdy23bndl2Du2ARtDs8A4xdzfhUjDBeOW1nNpJ6lXmsEpsply2SfQ==} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + sift@17.1.3: + resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + 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@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + swagger-jsdoc@6.3.0: + resolution: {integrity: sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==} + engines: {node: '>=20.0.0'} + hasBin: true + + swagger-ui-dist@5.32.8: + resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==} + + 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' + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + systeminformation@5.31.7: + resolution: {integrity: sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==} + engines: {node: '>=8.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript-eslint@8.60.1: + resolution: {integrity: sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + 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'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + winston-daily-rotate-file@5.0.0: + resolution: {integrity: sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==} + engines: {node: '>=8'} + peerDependencies: + winston: ^3 + + 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'} + + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.0.0-1: + resolution: {integrity: sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==} + engines: {node: '>= 6'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-spinner@1.2.0: + resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==} + engines: {node: '>=18.19'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + yup@1.7.1: + resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@colors/colors@1.6.0': {} + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@date-fns/tz@1.5.0': {} + + '@dotenvx/dotenvx@1.74.2': + dependencies: + commander: 11.1.0 + conf: 10.2.0 + dotenv: 17.4.2 + eciesjs: 0.4.18 + enquirer: 2.4.1 + env-paths: 2.2.1 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.4) + ignore: 5.3.2 + object-treeify: 1.1.33 + open: 8.4.2 + picomatch: 4.0.4 + systeminformation: 5.31.7 + undici: 7.28.0 + which: 4.0.0 + yocto-spinner: 1.2.0 + + '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.7.2(eslint@10.4.1(jiti@2.7.0))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.4.1(jiti@2.7.0) + ignore: 7.0.5 + + '@eslint-community/eslint-plugin-eslint-comments@4.7.2(eslint@10.7.0(jiti@2.7.0))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.7.0(jiti@2.7.0) + ignore: 7.0.5 + + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.1(jiti@2.7.0))': + dependencies: + eslint: 10.4.1(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0(jiti@2.7.0))': + dependencies: + eslint: 10.7.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.4.1(jiti@2.7.0))': + optionalDependencies: + eslint: 10.4.1(jiti@2.7.0) + + '@eslint/js@10.0.1(eslint@10.7.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.7.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + + '@fontsource-variable/inter@5.2.8': {} + + '@hono/node-server@1.19.14(hono@4.12.26)': + dependencies: + hono: 4.12.26 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.26) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.6.0(express@5.2.1) + hono: 4.12.26 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@mongodb-js/saslprep@1.4.11': + dependencies: + sparse-bitfield: 3.0.3 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@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 + + '@oxc-project/types@0.133.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@radix-ui/number@1.1.2': {} + + '@radix-ui/primitive@1.1.4': {} + + '@radix-ui/react-accessible-icon@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-accordion@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-alert-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-aspect-ratio@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-avatar@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-checkbox@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-collapsible@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-collection@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-context-menu@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-context@1.1.4(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-direction@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-dropdown-menu@2.1.18(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-form@0.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-hover-card@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-label@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-menu@2.1.18(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-menubar@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-navigation-menu@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-one-time-password-field@0.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-password-toggle-field@0.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-popover@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-popper@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-progress@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-radio-group@1.4.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-roving-focus@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-scroll-area@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-select@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-separator@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-slider@1.4.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-slot@1.3.0(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-switch@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-tabs@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-toast@1.2.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-toggle-group@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-toggle@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-toolbar@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-tooltip@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/rect@1.1.2': {} + + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.16)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.7 + react-redux: 9.3.0(@types/react@19.2.16)(react@19.2.7)(redux@5.0.1) + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7 + picomatch: 4.0.4 + rolldown: 1.0.3 + optionalDependencies: + '@babel/runtime': 7.29.7 + vite: 8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + + '@rolldown/pluginutils@1.0.1': {} + + '@scarf/scarf@1.4.0': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@stablelib/base64@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.22.2 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + + '@tanstack/query-core@5.101.0': {} + + '@tanstack/react-query@5.101.0(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.0 + react: 19.2.7 + + '@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/table-core@8.21.3': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.5 + path-browserify: 1.0.1 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 24.12.4 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.12.4 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.6 + '@types/node': 24.12.4 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.4 + + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + + '@types/cookiejar@2.1.5': {} + + '@types/cors@2.8.19': + dependencies: + '@types/node': 24.12.4 + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 24.12.4 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/geojson@7946.0.16': {} + + '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.16)': + dependencies: + '@types/react': 19.2.16 + hoist-non-react-statics: 3.3.2 + + '@types/http-errors@2.0.5': {} + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.4 + + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/methods@1.1.4': {} + + '@types/ms@2.1.0': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@19.2.3(@types/react@19.2.16)': + dependencies: + '@types/react': 19.2.16 + + '@types/react@19.2.16': + dependencies: + csstype: 3.2.3 + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.12.4 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.12.4 + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.12.4 + form-data: 4.0.5 + + '@types/supertest@7.2.1': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@types/swagger-jsdoc@6.0.4': {} + + '@types/swagger-ui-express@4.1.8': + dependencies: + '@types/express': 5.0.6 + '@types/serve-static': 2.2.0 + + '@types/triple-beam@1.3.5': {} + + '@types/use-sync-external-store@0.0.6': {} + + '@types/validate-npm-package-name@4.0.2': {} + + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@13.0.0': + dependencies: + '@types/webidl-conversions': 7.0.3 + + '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/type-utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.60.1 + eslint: 10.4.1(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 10.7.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.60.1 + debug: 4.4.3 + eslint: 10.4.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + eslint: 10.7.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.60.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3) + '@typescript-eslint/types': 8.60.1 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.60.1': + dependencies: + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/visitor-keys': 8.60.1 + + '@typescript-eslint/scope-manager@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + + '@typescript-eslint/tsconfig-utils@8.60.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.4.1(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.7.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.60.1': {} + + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.60.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.60.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3) + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/visitor-keys': 8.60.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + eslint: 10.4.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + eslint: 10.7.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.60.1': + dependencies: + '@typescript-eslint/types': 8.60.1 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + babel-plugin-react-compiler: 1.0.0 + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@24.12.4)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + + '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + 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 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + + async@3.2.6: {} + + asynckit@0.4.0: {} + + atomically@1.7.0: {} + + axios@1.17.0: + dependencies: + follow-redirects: 1.16.0(debug@4.4.3) + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0(debug@4.4.3) + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + b4a@1.8.1: {} + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.7 + + balanced-match@4.0.4: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.4: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.6 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.6: + dependencies: + bare-path: 3.1.1 + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.33: {} + + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.8.0 + node-gyp-build: 4.8.4 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.33 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.367 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bson@7.2.0: {} + + buffer-equal-constant-time@1.0.1: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + call-me-maybe@1.0.2: {} + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001793: {} + + chai@6.2.2: {} + + chalk@5.6.2: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + + cloudinary@2.10.0: + dependencies: + lodash: 4.18.1 + + clsx@2.1.1: {} + + code-block-writer@13.0.3: {} + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + + 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@11.1.0: {} + + commander@14.0.3: {} + + commander@6.2.0: {} + + commondir@1.0.1: {} + + component-emitter@1.3.1: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + conf@10.2.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + atomically: 1.7.0 + debounce-fn: 4.0.0 + dot-prop: 6.0.1 + env-paths: 2.2.1 + json-schema-typed: 7.0.3 + onetime: 5.1.2 + pkg-up: 3.1.0 + semver: 7.8.1 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cookiejar@2.1.4: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@9.0.2(typescript@6.0.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + data-uri-to-buffer@4.0.1: {} + + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + + date-fns@4.4.0: {} + + debounce-fn@4.0.0: + dependencies: + mimic-fn: 3.1.0 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + decimal.js@10.6.0: {} + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@2.2.1: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff@8.0.4: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + eciesjs@0.4.18: + dependencies: + '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.367: {} + + emoji-regex@10.6.0: {} + + enabled@2.0.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.22.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@8.0.0: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + 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.4 + + es-toolkit@1.49.0: {} + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.4.1(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.4.1(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.2(eslint@10.4.1(jiti@2.7.0)): + dependencies: + eslint: 10.4.1(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.4.1(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + eslint@10.7.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + 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: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + expect-type@1.4.0: {} + + express-rate-limit@8.6.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.2.0 + transitivePeerDependencies: + - supports-color + + express-validator@7.3.2: + dependencies: + lodash: 4.18.1 + validator: 13.15.35 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + 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: {} + + fast-sha256@1.3.0: {} + + fast-uri@3.1.2: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fecha@4.2.3: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-stream-rotator@0.6.1: + dependencies: + moment: 2.30.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + + find-up@4.1.0: + dependencies: + 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@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fn.name@1.1.0: {} + + follow-redirects@1.16.0(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + + 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.4 + mime-types: 2.1.35 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + formik@2.4.9(@types/react@19.2.16)(react@19.2.7): + dependencies: + '@types/hoist-non-react-statics': 3.3.7(@types/react@19.2.16) + deepmerge: 2.2.1 + hoist-non-react-statics: 3.3.2 + lodash: 4.18.1 + lodash-es: 4.18.1 + react: 19.2.7 + react-fast-compare: 2.0.4 + tiny-warning: 1.0.3 + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/react' + + forwarded@0.2.0: {} + + framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 12.42.2 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + fresh@2.0.0: {} + + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + fuzzysort@3.1.0: {} + + gensync@1.0.0-beta.2: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-own-enumerable-keys@1.0.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + globals@17.6.0: {} + + globals@17.7.0: {} + + goober@2.1.19(csstype@3.2.3): + dependencies: + csstype: 3.2.3 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + helmet@8.3.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hono@4.12.26: {} + + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + husky@9.1.7: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immer@10.2.0: {} + + immer@11.1.8: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + internmap@2.0.3: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + is-regexp@3.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jiti@2.7.0: {} + + jose@6.2.3: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsdom@29.1.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@7.0.3: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.1 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + kareem@3.3.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + kuler@2.0.0: {} + + leaflet@1.9.4: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + lint-staged@17.0.7: + dependencies: + listr2: 10.2.1 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.2.4 + optionalDependencies: + yaml: 2.9.0 + + listr2@10.2.1: + dependencies: + cli-truncate: 5.2.0 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 10.0.0 + + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.18.1: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.mergewith@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + 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 + + lru-cache@11.5.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.21.0(react@19.2.7): + dependencies: + react: 19.2.7 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.1 + + math-intrinsics@1.1.0: {} + + mdn-data@2.27.1: {} + + media-typer@1.1.0: {} + + memory-pager@1.5.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-fn@3.1.0: {} + + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + moment@2.30.1: {} + + mongodb-connection-string-url@7.0.1: + dependencies: + '@types/whatwg-url': 13.0.0 + whatwg-url: 14.2.0 + + mongodb-memory-server-core@11.2.0: + dependencies: + async-mutex: 0.5.0 + camelcase: 6.3.0 + debug: 4.4.3 + find-cache-dir: 3.3.2 + follow-redirects: 1.16.0(debug@4.4.3) + https-proxy-agent: 7.0.6 + mongodb: 7.2.0 + new-find-package-json: 2.0.0 + semver: 7.8.1 + tar-stream: 3.2.0 + tslib: 2.8.1 + yauzl: 3.4.0 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - bare-abort-controller + - bare-buffer + - gcp-metadata + - kerberos + - mongodb-client-encryption + - react-native-b4a + - snappy + - socks + - supports-color + + mongodb-memory-server@11.2.0: + dependencies: + mongodb-memory-server-core: 11.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - bare-abort-controller + - bare-buffer + - gcp-metadata + - kerberos + - mongodb-client-encryption + - react-native-b4a + - snappy + - socks + - supports-color + + mongodb@7.2.0: + dependencies: + '@mongodb-js/saslprep': 1.4.11 + bson: 7.2.0 + mongodb-connection-string-url: 7.0.1 + + mongodb@7.5.0: + dependencies: + '@mongodb-js/saslprep': 1.4.11 + bson: 7.2.0 + mongodb-connection-string-url: 7.0.1 + + mongoose@9.8.0: + dependencies: + '@standard-schema/spec': 1.1.0 + kareem: 3.3.0 + mongodb: 7.5.0 + mpath: 0.9.0 + mquery: 6.0.0 + ms: 2.1.3 + sift: 17.1.3 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + + motion-dom@12.42.2: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + mpath@0.9.0: {} + + mquery@6.0.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + + new-find-package-json@2.0.0: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + node-addon-api@8.8.0: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp-build@4.8.4: {} + + node-releases@2.0.47: {} + + normalize-wheel@1.0.1: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-treeify@1.1.33: {} + + obug@2.1.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + 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 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.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 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + + p-locate@4.1.0: + 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.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@3.0.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pkce-challenge@5.0.1: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + + postal-mime@2.7.5: {} + + postcss-selector-parser@7.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + prettier@3.9.6: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + property-expr@2.0.6: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + radix-ui@1.6.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-accessible-icon': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-alert-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-aspect-ratio': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-avatar': 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-checkbox': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context-menu': 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-form': 0.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-hover-card': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menubar': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-one-time-password-field': 0.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-password-toggle-field': 0.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-progress': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-radio-group': 1.4.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slider': 1.4.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-switch': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast': 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toolbar': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + razorpay@2.9.8: + dependencies: + axios: 1.18.1 + transitivePeerDependencies: + - debug + - supports-color + + react-day-picker@10.0.1(@types/react@19.2.16)(react@19.2.7): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-easy-crop@6.2.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + normalize-wheel: 1.0.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-fast-compare@2.0.4: {} + + react-hot-toast@2.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + csstype: 3.2.3 + goober: 2.1.19(csstype@3.2.3) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-icons@5.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-redux@9.3.0(@types/react@19.2.16)(react@19.2.7)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + redux: 5.0.1 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + + react-router-dom@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react-router@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + react@19.2.7: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + recharts@3.9.0(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.16)(react@19.2.7)(redux@5.0.1))(react@19.2.7) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.49.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 17.0.2 + react-redux: 9.3.0(@types/react@19.2.16)(react@19.2.7)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.7) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + require-from-string@2.0.2: {} + + reselect@5.2.0: {} + + resend@6.18.0: + dependencies: + postal-mime: 2.7.5 + standardwebhooks: 1.0.0 + + resolve-from@4.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + 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: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.1: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shadcn@4.11.0(typescript@6.0.3): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@dotenvx/dotenvx': 1.74.2 + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.2 + commander: 14.0.3 + cosmiconfig: 9.0.2(typescript@6.0.3) + dedent: 1.7.2 + deepmerge: 4.3.1 + diff: 8.0.4 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.5 + fuzzysort: 3.1.0 + https-proxy-agent: 7.0.6 + kleur: 4.1.5 + node-fetch: 3.3.2 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + prompts: 2.4.2 + recast: 0.23.11 + stringify-object: 5.0.0 + tailwind-merge: 3.6.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - babel-plugin-macros + - supports-color + - typescript + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + sift@17.1.3: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + + stack-trace@0.0.10: {} + + stackback@0.0.2: {} + + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + stdin-discarder@0.2.2: {} + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-argv@0.3.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + 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.15.2 + 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@7.2.0: + dependencies: + has-flag: 4.0.0 + + swagger-jsdoc@6.3.0(openapi-types@12.1.3): + dependencies: + '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) + commander: 6.2.0 + doctrine: 3.0.0 + glob: 11.1.0 + lodash.mergewith: 4.6.2 + yaml: 2.0.0-1 + transitivePeerDependencies: + - openapi-types + + swagger-ui-dist@5.32.8: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@5.2.1): + dependencies: + express: 5.2.1 + swagger-ui-dist: 5.32.8 + + symbol-tree@3.2.4: {} + + systeminformation@5.31.7: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.4 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + text-hex@1.0.0: {} + + tiny-case@1.0.3: {} + + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toposort@2.0.2: {} + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + triple-beam@1.4.1: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.23.1: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@2.19.0: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript-eslint@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.4.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript-eslint@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.7.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + typescript@6.0.3: {} + + undici-types@6.21.0: {} + + undici-types@7.16.0: {} + + undici@7.28.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + util-deprecate@1.0.2: {} + + validate-npm-package-name@7.0.2: {} + + validator@13.15.35: {} + + vary@1.1.2: {} + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.19 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.1 + yaml: 2.9.0 + + vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.4 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.1 + yaml: 2.9.0 + + vitest@4.1.10(@types/node@22.19.19)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 29.1.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + + vitest@4.1.10(@types/node@24.12.4)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 29.1.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@7.0.0: {} + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + winston-daily-rotate-file@5.0.0(winston@3.19.0): + dependencies: + file-stream-rotator: 0.6.1 + object-hash: 3.0.0 + triple-beam: 1.4.1 + winston: 3.19.0 + winston-transport: 4.9.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: {} + + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.1 + strip-ansi: 7.2.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yaml@2.0.0-1: {} + + yaml@2.9.0: + optional: true + + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + + yocto-queue@0.1.0: {} + + yocto-spinner@1.2.0: + dependencies: + yoctocolors: 2.1.2 + + yoctocolors@2.1.2: {} + + yup@1.7.1: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@3.25.76: {} + + zod@4.4.3: {} + + zustand@5.0.14(@types/react@19.2.16)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + optionalDependencies: + '@types/react': 19.2.16 + immer: 11.1.8 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000000..5abd5d86e4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,14 @@ +packages: + - server + - client + - admin + +allowBuilds: + bcrypt: true + esbuild: true + +onlyBuiltDependencies: + - '@scarf/scarf' + - bcrypt + - esbuild + - mongodb-memory-server diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000000..862f0b6a5e --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,16 @@ +node_modules +dist +.env +.env.* +*.log +logs/ +.git +.gitignore +coverage +tests +scripts +tsconfig.tsbuildinfo +.prettierrc +.prettierignore +eslint.config.mjs +vitest.config.ts diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000000..9ea6a66d14 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,40 @@ +# Database +MONGODB_URI=mongodb://localhost:27017/book-my-venue + +# Email (Resend) +# Development Email Override (all emails in dev go here) +RESEND_DEV_RECIPIENT=dev@yourdomain.com +RESEND_API_KEY=re_your_api_key_here +EMAIL_FROM_NAME=BookMyVenue +EMAIL_FROM_EMAIL=noreply@bookmyvenue.com +APP_NAME=BookMyVenue +FRONTEND_URL=http://localhost:5173 +SERVER_URL=http://localhost:3000 + +# Server Configuration +PORT=3000 +NODE_ENV=development + +# Cloudinary +CLOUDINARY_CLOUD_NAME=your_cloud_name +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret +CLOUDINARY_UPLOAD_PRESET=bookmyvenue_venues + +# JWT Configuration +JWT_ACCESS_SECRET=your_secure_access_token_secret_here_min_32_chars +JWT_REFRESH_SECRET=your_secure_refresh_token_secret_here_min_32_chars +JWT_ISSUER=BookMyVenue +JWT_AUDIENCE=BookMyVenue +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRY=15m +REFRESH_TOKEN_EXPIRY=7d + +# Razorpay Payment Gateway +RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxx +RAZORPAY_KEY_SECRET=your_razorpay_key_secret +RAZORPAY_WEBHOOK_SECRET=your_razorpay_webhook_secret + +# Swagger UI (development only) +SWAGGER_USER=admin +SWAGGER_PASS=password \ No newline at end of file diff --git a/server/.prettierignore b/server/.prettierignore new file mode 100644 index 0000000000..9aec4249bd --- /dev/null +++ b/server/.prettierignore @@ -0,0 +1,13 @@ +.env +.env.local +.git +.idea +.vscode +*.log +coverage +dist +logs +node_modules +package-lock.json +pnpm-lock.yaml +pnpm-workspace.yaml \ No newline at end of file diff --git a/server/.prettierrc b/server/.prettierrc new file mode 100644 index 0000000000..32a2397321 --- /dev/null +++ b/server/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000000..beaaeaa6e6 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,56 @@ +# BookMyVenue — Server Dockerfile +# ============================================================================= + +# Stage Install dependencies +FROM node:22-alpine AS deps + +WORKDIR /app + +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ + +RUN pnpm install --frozen-lockfile + +# Stage 2: Build TypeScript +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY --from=deps /app/node_modules ./node_modules + +COPY tsconfig.json ./ +COPY src/ ./src/ + +RUN corepack enable && pnpm build + +# Stage 3: Production runner +FROM node:22-alpine AS runner + +WORKDIR /app + +# Create non-root user +RUN addgroup -S appgroup && adduser -S appuser -G appgroup -u 1001 + +# Copy production dependencies +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN apk add --no-cache --virtual .build-deps python3 make g++ \ + && corepack enable \ + && pnpm install --frozen-lockfile --prod \ + && apk del .build-deps + +# Copy built artifacts from build stage +COPY --from=build /app/dist ./dist + +# Log directory — writable by non-root user +RUN mkdir -p logs && chown -R appuser:appgroup logs + +EXPOSE 3003 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/v1/health || exit 1 + +USER appuser + +CMD ["node", "dist/server.js"] diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs new file mode 100644 index 0000000000..37d1fc9b68 --- /dev/null +++ b/server/eslint.config.mjs @@ -0,0 +1,98 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import eslintComments from '@eslint-community/eslint-plugin-eslint-comments'; + +export default tseslint.config( + { + ignores: ['dist/**', 'scripts/**', 'coverage/**', 'eslint.config.mjs'], + }, + js.configs.recommended, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + globals: globals.node, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + plugins: { + 'eslint-comments': eslintComments, + }, + rules: { + 'eslint-comments/no-unlimited-disable': 'error', + 'eslint-comments/no-use': 'error', + 'eslint-comments/require-description': 'error', + }, + }, + { + rules: { + // TypeScript strictness + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + '@typescript-eslint/no-unsafe-argument': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], + '@typescript-eslint/no-import-type-side-effects': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/require-await': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/prefer-nullish-coalescing': 'error', + '@typescript-eslint/prefer-optional-chain': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + + // Error handling + '@typescript-eslint/only-throw-error': 'error', + + // General quality + 'no-undef': 'warn', + 'no-console': 'error', + 'no-debugger': 'error', + 'no-return-await': 'off', + '@typescript-eslint/return-await': ['error', 'in-try-catch'], + eqeqeq: ['error', 'always'], + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'error', + }, + }, + + // Per-file overrides (keep minimal, document why) + { + files: ['src/types/express.ts'], + rules: { + // declare global { namespace Express { ... } } is the only valid TypeScript + // mechanism for augmenting Express Request types — namespace is required here. + '@typescript-eslint/no-namespace': 'off', + }, + }, + // Test files — disable type-aware linting (test patterns like supertest chaining, + // mock data, and dynamic assertions conflict with strict type-checked rules). + { + files: ['tests/**/*.ts', 'vitest.config.ts'], + languageOptions: { + parserOptions: { + projectService: null, + }, + }, + rules: { + // Disable all type-aware rules inherited from strictTypeChecked + stylisticTypeChecked + ...Object.fromEntries( + Object.entries(tseslint.configs.disableTypeChecked.rules).map(([key]) => [key, 'off']) + ), + // Also disable non-type-aware strict rules that fight test patterns + '@typescript-eslint/explicit-function-return-type': 'off', + }, + }, +); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000000..657fe127e5 --- /dev/null +++ b/server/package.json @@ -0,0 +1,71 @@ +{ + "name": "server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "dev": "tsx watch src/server.ts", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", + "typecheck": "tsc --noEmit", + "format": "prettier --write \"src/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\"", + "format:fix": "prettier --write .", + "script": "tsx scripts/configs/runner.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.16.1", + "dependencies": { + "bcrypt": "^6.0.0", + "brotli": "^1.3.3", + "cloudinary": "^2.10.0", + "compression": "^1.8.1", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "eslint": "^10.7.0", + "express": "^5.2.1", + "express-rate-limit": "^8.6.0", + "express-validator": "^7.3.2", + "helmet": "^8.3.0", + "jsonwebtoken": "^9.0.3", + "mongoose": "^9.8.0", + "razorpay": "^2.9.8", + "resend": "^6.18.0", + "swagger-jsdoc": "^6.3.0", + "swagger-ui-express": "^5.0.1", + "winston": "^3.19.0", + "winston-daily-rotate-file": "^5.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", + "@eslint/js": "^10.0.1", + "@types/bcrypt": "^6.0.0", + "@types/compression": "^1.8.1", + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^22.13.0", + "@types/supertest": "^7.2.1", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", + "@vitest/coverage-v8": "^4.0.0", + "globals": "^17.7.0", + "mongodb-memory-server": "^11.2.0", + "prettier": "^3.9.6", + "supertest": "^7.2.2", + "tsx": "^4.23.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.65.0", + "vitest": "^4.0.0" + } +} diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml new file mode 100644 index 0000000000..4ac6f3672a --- /dev/null +++ b/server/pnpm-lock.yaml @@ -0,0 +1,4082 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 + brotli: + specifier: ^1.3.3 + version: 1.3.3 + cloudinary: + specifier: ^2.10.0 + version: 2.10.0 + compression: + specifier: ^1.8.1 + version: 1.8.1 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 + cors: + specifier: ^2.8.6 + version: 2.8.6 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + eslint: + specifier: ^10.7.0 + version: 10.7.0 + express: + specifier: ^5.2.1 + version: 5.2.1 + express-rate-limit: + specifier: ^8.6.0 + version: 8.6.0(express@5.2.1) + express-validator: + specifier: ^7.3.2 + version: 7.3.2 + helmet: + specifier: ^8.3.0 + version: 8.3.0 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + mongoose: + specifier: ^9.8.0 + version: 9.8.0 + razorpay: + specifier: ^2.9.8 + version: 2.9.8 + resend: + specifier: ^6.18.0 + version: 6.18.0 + swagger-jsdoc: + specifier: ^6.3.0 + version: 6.3.0(openapi-types@12.1.3) + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@5.2.1) + winston: + specifier: ^3.19.0 + version: 3.19.0 + winston-daily-rotate-file: + specifier: ^5.0.0 + version: 5.0.0(winston@3.19.0) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.7.2 + version: 4.7.2(eslint@10.7.0) + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.7.0) + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@5.0.6) + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/node': + specifier: ^22.13.0 + version: 22.20.1 + '@types/supertest': + specifier: ^7.2.1 + version: 7.2.1 + '@types/swagger-jsdoc': + specifier: ^6.0.4 + version: 6.0.4 + '@types/swagger-ui-express': + specifier: ^4.1.8 + version: 4.1.8 + '@vitest/coverage-v8': + specifier: ^4.0.0 + version: 4.1.10(vitest@4.1.10) + globals: + specifier: ^17.7.0 + version: 17.7.0 + mongodb-memory-server: + specifier: ^11.2.0 + version: 11.2.0 + prettier: + specifier: ^3.9.6 + version: 3.9.6 + supertest: + specifier: ^7.2.2 + version: 7.2.2 + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.65.0 + version: 8.65.0(eslint@10.7.0)(typescript@5.9.3) + vitest: + specifier: ^4.0.0 + version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@7.3.6(@types/node@22.20.1)(tsx@4.23.1)) + +packages: + + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@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@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.7.2': + resolution: {integrity: sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@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/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mongodb-js/saslprep@1.4.12': + resolution: {integrity: sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + + '@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/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.2': + resolution: {integrity: sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@7.2.1': + resolution: {integrity: sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==} + + '@types/swagger-jsdoc@6.0.4': + resolution: {integrity: sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==} + + '@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/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + + '@types/whatwg-url@13.0.0': + resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} + + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + 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@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.6: + resolution: {integrity: sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + bson@7.3.1: + resolution: {integrity: sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==} + engines: {node: '>=20.19.0'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + cloudinary@2.10.0: + resolution: {integrity: sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==} + engines: {node: '>=9'} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + 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'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + 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'} + + 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'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + 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'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + 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-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.7.0: + resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + 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'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + 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'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.6.0: + resolution: {integrity: sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express-validator@7.3.2: + resolution: {integrity: sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==} + engines: {node: '>= 8.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + 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==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-stream-rotator@0.6.1: + resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=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@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + 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'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + 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 + + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + 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.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + kareem@3.3.0: + resolution: {integrity: sha512-kpSuLD3/7RenBnjnJdOHXCKC8dTd1JzeOiJhN0necWWci6cC+qX+VuwPnMVgb+a4+KNJSfgqahpnfWaeDXCimw==} + engines: {node: '>=18.0.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + mongodb-connection-string-url@7.0.2: + resolution: {integrity: sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==} + engines: {node: '>=20.19.0'} + + mongodb-memory-server-core@11.2.0: + resolution: {integrity: sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==} + engines: {node: '>=20.19.0'} + + mongodb-memory-server@11.2.0: + resolution: {integrity: sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==} + engines: {node: '>=20.19.0'} + + mongodb@7.5.0: + resolution: {integrity: sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.806.0 + '@mongodb-js/zstd': ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: ^7.2.0 + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + mongoose@9.8.0: + resolution: {integrity: sha512-PDGx3XACxrBQyWf4YT+5s1Xsx19x84UWGlRVIza4i9RG6qKjGcoG7odotT6uquR6YoaDMTZ6ZZc/jMXLrNPyyA==} + engines: {node: '>=20.19.0'} + + mpath@0.9.0: + resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==} + engines: {node: '>=4.0.0'} + + mquery@6.0.0: + resolution: {integrity: sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==} + engines: {node: '>=20.19.0'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + new-find-package-json@2.0.0: + resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==} + engines: {node: '>=12.22.0'} + + node-addon-api@8.9.0: + resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + 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==} + + 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'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + 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'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + postal-mime@2.7.5: + resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==} + + postcss@8.5.21: + resolution: {integrity: sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + razorpay@2.9.8: + resolution: {integrity: sha512-Uqv3KV7JAfpLlabGdg8Cw7e7ieGkK/DighFq6iGkNnz0Y+it6+/lFmPCTfkcptc2EagL2Og25VWUxc+Fgp/+nA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resend@6.18.0: + resolution: {integrity: sha512-EjxZ9AVzywJgOlUoIJe9ytBWVrfbUtJbjeoLnRSvpU1sv97Hh9DSwhw+k8kiujrG4Rg4bzTBsjlmwWWuoOxSug==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + 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==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + sift@17.1.3: + resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + 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@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + swagger-jsdoc@6.3.0: + resolution: {integrity: sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==} + engines: {node: '>=20.0.0'} + hasBin: true + + swagger-ui-dist@5.32.10: + resolution: {integrity: sha512-RTrAPrp/J5H/H8JvRBOpR3qCsIhntOweFwaasR0TD2Y6a/7VUYYFnjyB3MFe1niAg3VkEQGT9pVZ0r225jvJJA==} + + 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' + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + winston-daily-rotate-file@5.0.0: + resolution: {integrity: sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==} + engines: {node: '>=8'} + peerDependencies: + winston: ^3 + + 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'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yaml@2.0.0-1: + resolution: {integrity: sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==} + engines: {node: '>= 6'} + + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.3.0 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@colors/colors@1.6.0': {} + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.7.2(eslint@10.7.0)': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.7.0 + ignore: 7.0.6 + + '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0)': + dependencies: + eslint: 10.7.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.7.0)': + optionalDependencies: + eslint: 10.7.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mongodb-js/saslprep@1.4.12': + dependencies: + sparse-bitfield: 3.0.3 + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@stablelib/base64@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 22.20.1 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.20.1 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.6 + '@types/node': 22.20.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.20.1 + + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + + '@types/cookiejar@2.1.5': {} + + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.20.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.2': + dependencies: + '@types/node': 22.20.1 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.2 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.20.1 + + '@types/methods@1.1.4': {} + + '@types/ms@2.1.0': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.20.1 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.20.1 + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.20.1 + form-data: 4.0.6 + + '@types/supertest@7.2.1': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@types/swagger-jsdoc@6.0.4': {} + + '@types/swagger-ui-express@4.1.8': + dependencies: + '@types/express': 5.0.6 + '@types/serve-static': 2.2.0 + + '@types/triple-beam@1.3.5': {} + + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@13.0.0': + dependencies: + '@types/webidl-conversions': 7.0.3 + + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.65.0(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 10.7.0 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + eslint: 10.7.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.65.0(eslint@10.7.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.7.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.65.0(eslint@10.7.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + eslint: 10.7.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@7.3.6(@types/node@22.20.1)(tsx@4.23.1)) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@7.3.6(@types/node@22.20.1)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1)(tsx@4.23.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + 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 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + argparse@2.0.1: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + + async@3.2.6: {} + + asynckit@0.4.0: {} + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0(debug@4.4.3) + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + b4a@1.8.1: {} + + balanced-match@4.0.4: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.4: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.6 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.6: + dependencies: + bare-path: 3.1.1 + + base64-js@1.5.1: {} + + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.9.0 + node-gyp-build: 4.8.4 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + bson@7.3.1: {} + + buffer-equal-constant-time@1.0.1: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + call-me-maybe@1.0.2: {} + + camelcase@6.3.0: {} + + chai@6.2.2: {} + + cloudinary@2.10.0: + dependencies: + lodash: 4.18.1 + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + + 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: {} + + commondir@1.0.1: {} + + component-emitter@1.3.1: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + enabled@2.0.0: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + 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.4 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.7.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + expect-type@1.4.0: {} + + express-rate-limit@8.6.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.2.0 + transitivePeerDependencies: + - supports-color + + express-validator@7.3.2: + dependencies: + lodash: 4.18.1 + validator: 13.15.35 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fast-sha256@1.3.0: {} + + fast-uri@3.1.4: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fecha@4.2.3: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-stream-rotator@0.6.1: + dependencies: + moment: 2.30.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + 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@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fn.name@1.1.0: {} + + follow-redirects@1.16.0(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + 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@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + globals@17.7.0: {} + + gopd@1.2.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.4: + dependencies: + function-bind: 1.1.2 + + helmet@8.3.0: {} + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + js-tokens@10.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + kareem@3.3.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kuler@2.0.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.mergewith@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash@4.18.1: {} + + 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 + + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + memory-pager@1.5.0: {} + + merge-descriptors@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minipass@7.1.3: {} + + moment@2.30.1: {} + + mongodb-connection-string-url@7.0.2: + dependencies: + '@types/whatwg-url': 13.0.0 + whatwg-url: 14.2.0 + + mongodb-memory-server-core@11.2.0: + dependencies: + async-mutex: 0.5.0 + camelcase: 6.3.0 + debug: 4.4.3 + find-cache-dir: 3.3.2 + follow-redirects: 1.16.0(debug@4.4.3) + https-proxy-agent: 7.0.6 + mongodb: 7.5.0 + new-find-package-json: 2.0.0 + semver: 7.8.5 + tar-stream: 3.2.0 + tslib: 2.8.1 + yauzl: 3.4.0 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - bare-abort-controller + - bare-buffer + - gcp-metadata + - kerberos + - mongodb-client-encryption + - react-native-b4a + - snappy + - socks + - supports-color + + mongodb-memory-server@11.2.0: + dependencies: + mongodb-memory-server-core: 11.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - bare-abort-controller + - bare-buffer + - gcp-metadata + - kerberos + - mongodb-client-encryption + - react-native-b4a + - snappy + - socks + - supports-color + + mongodb@7.5.0: + dependencies: + '@mongodb-js/saslprep': 1.4.12 + bson: 7.3.1 + mongodb-connection-string-url: 7.0.2 + + mongoose@9.8.0: + dependencies: + '@standard-schema/spec': 1.1.0 + kareem: 3.3.0 + mongodb: 7.5.0 + mpath: 0.9.0 + mquery: 6.0.0 + ms: 2.1.3 + sift: 17.1.3 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + + mpath@0.9.0: {} + + mquery@6.0.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + + new-find-package-json@2.0.0: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + node-addon-api@8.9.0: {} + + node-gyp-build@4.8.4: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + obug@2.1.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + 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 + + 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 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + 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: {} + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + postal-mime@2.7.5: {} + + postcss@8.5.21: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.6: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + razorpay@2.9.8: + dependencies: + axios: 1.18.1 + transitivePeerDependencies: + - debug + - supports-color + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-from-string@2.0.2: {} + + resend@6.18.0: + dependencies: + postal-mime: 2.7.5 + standardwebhooks: 1.0.0 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + sift@17.1.3: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + + stack-trace@0.0.10: {} + + stackback@0.0.2: {} + + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + 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@7.2.0: + dependencies: + has-flag: 4.0.0 + + swagger-jsdoc@6.3.0(openapi-types@12.1.3): + dependencies: + '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) + commander: 6.2.0 + doctrine: 3.0.0 + glob: 11.1.0 + lodash.mergewith: 4.6.2 + yaml: 2.0.0-1 + transitivePeerDependencies: + - openapi-types + + swagger-ui-dist@5.32.10: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@5.2.1): + dependencies: + express: 5.2.1 + swagger-ui-dist: 5.32.10 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.4 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + text-hex@1.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + toidentifier@1.0.1: {} + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + triple-beam@1.4.1: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: {} + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript-eslint@8.65.0(eslint@10.7.0)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0)(typescript@5.9.3) + eslint: 10.7.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + validator@13.15.35: {} + + vary@1.1.2: {} + + vite@7.3.6(@types/node@22.20.1)(tsx@4.23.1): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.21 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + tsx: 4.23.1 + + vitest@4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@7.3.6(@types/node@22.20.1)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.6(@types/node@22.20.1)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.6(@types/node@22.20.1)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + transitivePeerDependencies: + - msw + + webidl-conversions@7.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + winston-daily-rotate-file@5.0.0(winston@3.19.0): + dependencies: + file-stream-rotator: 0.6.1 + object-hash: 3.0.0 + triple-beam: 1.4.1 + winston: 3.19.0 + winston-transport: 4.9.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: {} + + wrappy@1.0.2: {} + + yaml@2.0.0-1: {} + + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + + yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/server/pnpm-workspace.yaml b/server/pnpm-workspace.yaml new file mode 100644 index 0000000000..83663413a8 --- /dev/null +++ b/server/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +onlyBuiltDependencies: + - '@scarf/scarf' + - bcrypt + - esbuild + - mongodb-memory-server diff --git a/server/public/images/bmv-logo.png b/server/public/images/bmv-logo.png new file mode 100644 index 0000000000..4e77f83291 Binary files /dev/null and b/server/public/images/bmv-logo.png differ diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000000..b5d7af2cdd --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,72 @@ +import compression from 'compression'; +import cors from 'cors'; +import express from 'express'; +import type { Express, NextFunction, Request, Response } from 'express'; +import cookieParser from 'cookie-parser'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; + +import { corsOptions } from './configs/cors.config'; +import router from './router'; +import { ResponseUtil } from './utils/responseUtils'; +import { requestLogger, logError } from './utils/logger'; +import { webhookRouter } from './modules/webhook/webhook.router'; + +const app: Express = express(); + +// Trust the first proxy hop so express-rate-limit can read X-forwarded-For +app.set('trust proxy', 1); + +// Security middleware +app.use(helmet()); +app.use( + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 200, + message: 'Too many requests from this IP, please try again after 15 minutes', + }) +); + +// ─── WEBHOOK: Mount BEFORE express.json() ────────────────────────────────── +// Razorpay HMAC-SHA256 verification requires the raw request body as a Buffer. +app.use('/api/v1/webhook', webhookRouter); + +// Body parsing middleware +app.use(express.json({ limit: '10kb' })); +app.use(express.urlencoded({ extended: true, limit: '10kb' })); + +// CORS and cookies +app.use(cors(corsOptions)); +app.use(cookieParser()); + +// Compression middleware +app.use(compression({ threshold: 1024 })); + +// Cache control +app.use(express.static('public')); + +app.use((_req, res, next) => { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + next(); +}); + +// HTTP request logging +app.use(requestLogger); + +// Router +app.use('/api/v1', router); + +// 404 Handler +app.use((_req: Request, res: Response) => { + ResponseUtil.notFound(res, 'Route/Method not found'); +}); + +// Global error handler +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + logError('Unhandled Express error', { error: err.message, stack: err.stack }); + ResponseUtil.internalServerError(res, 'Internal server error'); +}); + +export { app }; diff --git a/server/src/configs/cors.config.ts b/server/src/configs/cors.config.ts new file mode 100644 index 0000000000..b947184dc2 --- /dev/null +++ b/server/src/configs/cors.config.ts @@ -0,0 +1,32 @@ +import type { CorsOptions } from 'cors'; +import { nodeEnv } from '../constants/env'; + +function parseOrigins(env: string | undefined, fallback: string[]): string[] { + if (!env) return fallback; + const origins = env + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + for (const o of origins) { + const u = new URL(o); + if (u.protocol !== 'https:' && nodeEnv === 'production') { + throw new Error(`Non-HTTPS CORS origin in production: ${o}`); + } + } + return origins; +} + +export const corsOptions: CorsOptions = { + origin: + nodeEnv === 'production' + ? parseOrigins(process.env.CLIENT_URL, ['https://bookmyvenue.com']) + : parseOrigins(process.env.CLIENT_URL, [ + 'https://bmvserver.shares.zrok.io', + 'http://localhost:5173', + 'http://localhost:3000', + 'http://localhost:5174', + 'http://localhost:3001', + ]), + credentials: true, + optionsSuccessStatus: 204, +}; diff --git a/server/src/configs/database.config.ts b/server/src/configs/database.config.ts new file mode 100644 index 0000000000..b0757cf8d9 --- /dev/null +++ b/server/src/configs/database.config.ts @@ -0,0 +1,55 @@ +import mongoose from 'mongoose'; +import { logInfo, logError } from '../utils/logger'; + +export let dbSupportsTransactions = false; + +export const connectDatabase = async (): Promise => { + const mongodburi = process.env.MONGODB_URI; + + if (!mongodburi) { + logError('MONGODB_URI environment variable is not set', { + module: 'database.config.ts/connectDatabase', + }); + process.exit(1); + } + + try { + const connection = await mongoose.connect(mongodburi, { + autoIndex: process.env.NODE_ENV !== 'production', + }); + logInfo('DB connected'); + + const client = mongoose.connection.getClient(); + const topologyType = (client as unknown as { topology?: { description?: { type?: string } } }) + .topology?.description?.type; + dbSupportsTransactions = + typeof topologyType === 'string' && + (topologyType.includes('ReplicaSet') || topologyType.includes('Sharded')); + + logInfo('DB topology details', { + topology: topologyType ?? 'unknown', + txSupported: dbSupportsTransactions, + }); + + return connection; + } catch (error) { + logError('Failed to connect to MongoDB', { + module: 'database.config.ts/connectDatabase', + error: (error as Error).message, + }); + process.exit(1); + } +}; + +export const disconnectDatabase = async (): Promise => { + try { + await mongoose.disconnect(); + logInfo('Disconnected from MongoDB'); + } catch (error) { + logError('Failed to disconnect from MongoDB', { + module: 'database.config.ts/disconnectDatabase', + error: (error as Error).message, + }); + process.exit(1); + } +}; diff --git a/server/src/configs/envValidation.config.ts b/server/src/configs/envValidation.config.ts new file mode 100644 index 0000000000..fe0a93e760 --- /dev/null +++ b/server/src/configs/envValidation.config.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { logError, logInfo } from '../utils/logger'; +import { nodeEnv } from '../constants/env'; + +const envSchema = z.object({ + MONGODB_URI: z.string().min(1, 'MONGODB_URI is required'), + JWT_ACCESS_SECRET: z.string().min(1, 'JWT_ACCESS_SECRET is required'), + JWT_REFRESH_SECRET: z.string().min(1, 'JWT_REFRESH_SECRET is required'), + JWT_ISSUER: z.string().min(1, 'JWT_ISSUER is required'), + JWT_AUDIENCE: z.string().min(1, 'JWT_AUDIENCE is required'), + RAZORPAY_KEY_ID: z.string().min(1, 'RAZORPAY_KEY_ID is required'), + RAZORPAY_KEY_SECRET: z.string().min(1, 'RAZORPAY_KEY_SECRET is required'), + RESEND_API_KEY: z.string().min(1, 'RESEND_API_KEY is required'), + PORT: z.coerce.number().positive().optional(), +}); + +const productionEnvSchema = envSchema.extend({ + SWAGGER_USER: z.string().min(1, 'SWAGGER_USER is required in production'), + SWAGGER_PASS: z.string().min(1, 'SWAGGER_PASS is required in production'), +}); + +export function validateEnv(): void { + const schema = nodeEnv === 'production' ? productionEnvSchema : envSchema; + const result = schema.safeParse(process.env); + + if (!result.success) { + const errorMessages: string[] = []; + for (const issue of result.error.issues) { + const path = issue.path.length > 0 ? String(issue.path[0]) : 'unknown'; + errorMessages.push(`${path}: ${issue.message}`); + } + logError('Environment validation failed', { errors: errorMessages }); + process.exit(1); + } + + logInfo('Env validated'); +} diff --git a/server/src/constants/auth.constants.ts b/server/src/constants/auth.constants.ts new file mode 100644 index 0000000000..31ceead022 --- /dev/null +++ b/server/src/constants/auth.constants.ts @@ -0,0 +1,26 @@ +export const TokenRevocationReason = { + ADMIN_REVOKED: 'admin:revoked', + EXPIRED: 'system:expired', + INVALID: 'system:invalid', + PASSWORD_CHANGED: 'system:password_changed', + PASSWORD_REUSE_ATTEMPT: 'system:password_reuse_attempt', + SECURITY_BREACH: 'admin:security_breach', + SUSPICIOUS_ACTIVITY: 'system:suspicious_activity', + TOKEN_ROTATION: 'system:token_rotation', + USER_LOGIN: 'system:user_login', + USER_LOGOUT: 'user:manual_logout', + USER_REVOKED: 'user:revoked', +} as const; + +export type TokenRevocationReasonType = + (typeof TokenRevocationReason)[keyof typeof TokenRevocationReason]; + +export const AuthConstants = { + MAX_ACTIVE_TOKENS: 3, + MAX_EMAILS_PER_HR: 5, + MAX_REQUESTS_PER_HR: 10, + RESEND_COOLDOWN_MS: 30 * 1000, // 30s + TOKEN_EXPIRY_MS: 15 * 60 * 1000, //15mins + SESSION_ABSOLUTE_EXPIRY_MS: 30 * 24 * 60 * 60 * 1000, // 30d + NEW_SESSION_REVOKE_LOCK_MS: 48 * 60 * 60 * 1000, // 48h +} as const; diff --git a/server/src/constants/booking.constants.ts b/server/src/constants/booking.constants.ts new file mode 100644 index 0000000000..2546ab26ff --- /dev/null +++ b/server/src/constants/booking.constants.ts @@ -0,0 +1,8 @@ +export type BookingStatusType = (typeof BookingStatus)[keyof typeof BookingStatus]; + +export const BookingStatus = { + CONFIRMED: 'confirmed', + CANCELLED: 'cancelled', + COMPLETED: 'completed', + IN_PROGRESS: 'in_progress', +} as const; diff --git a/server/src/constants/common.ts b/server/src/constants/common.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/src/constants/email.constants.ts b/server/src/constants/email.constants.ts new file mode 100644 index 0000000000..c68720c287 --- /dev/null +++ b/server/src/constants/email.constants.ts @@ -0,0 +1,36 @@ +export type EmailIntentType = (typeof EmailIntent)[keyof typeof EmailIntent]; +export type EmailTaskStatusType = (typeof EmailTaskStatus)[keyof typeof EmailTaskStatus]; + +export const EmailIntent = { + ACCOUNT_NOTIFICATION: 'account_notification', + EMAIL_VERIFICATION: 'email_verification', + PASSWORD_RESET: 'password_reset', + ADMIN_PASSWORD_RESET: 'admin_password_reset', + SECURITY_ALERT: 'security_alert', + WELCOME: 'welcome', + BOOKING_CONFIRMATION: 'booking_confirmation', + BOOKING_REFUND: 'booking_refund', + BOOKING_CANCELLATION: 'booking_cancellation', + VENUE_APPROVED: 'venue_approved', + VENUE_REJECTED: 'venue_rejected', + VENUE_SUSPENDED: 'venue_suspended', + VENUE_UNSUSPENDED: 'venue_unsuspended', + VENUE_DEADLINE_EXTENDED: 'venue_deadline_extended', + USER_BANNED: 'user_banned', + USER_UNBANNED: 'user_unbanned', + REVIEW_REMOVED: 'review_removed', + REVIEW_RESTORED: 'review_restored', +} as const; + +export const EmailTaskStatus = { + COMPLETED: 'completed', + FAILED: 'failed', + PENDING: 'pending', + QUEUED: 'queued', +} as const; + +export const EmailConstants = { + MAX_RETRIES: 3, + POLL_INTERVAL_MS: 5000, // 5 seconds + STALE_CUTOFF_MS: 5 * 60 * 1000, // 5 minutes +} as const; diff --git a/server/src/constants/env.ts b/server/src/constants/env.ts new file mode 100644 index 0000000000..4c40d923f6 --- /dev/null +++ b/server/src/constants/env.ts @@ -0,0 +1,79 @@ +import type jwt from 'jsonwebtoken'; + +export const nodeEnv = process.env.NODE_ENV ?? 'development'; + +export const resendConfig = { + get apiKey(): string | undefined { + return process.env.RESEND_API_KEY; + }, + get fromName(): string | undefined { + return process.env.EMAIL_FROM_NAME; + }, + get fromEmail(): string | undefined { + return process.env.EMAIL_FROM_EMAIL; + }, + get devToEmail(): string | undefined { + return process.env.RESEND_DEV_RECIPIENT; + }, + get appName(): string { + return process.env.APP_NAME ?? 'BookMyVenue'; + }, + get frontendUrl(): string | undefined { + return process.env.FRONTEND_URL; + }, + get serverUrl(): string { + return process.env.SERVER_URL ?? 'http://localhost:3003'; + }, + get logoUrl(): string { + const base = process.env.SERVER_URL ?? 'http://localhost:3003'; + return `${base}/images/bmv-logo.png`; + }, +}; + +export const jwtConfig = { + get issuer(): string { + return process.env.JWT_ISSUER ?? 'BookMyVenue'; + }, + get audience(): string { + return process.env.JWT_AUDIENCE ?? 'BookMyVenue'; + }, + get algorithm(): jwt.Algorithm { + return (process.env.JWT_ALGORITHM ?? 'HS256') as jwt.Algorithm; + }, +}; + +export const razorpayConfig = { + get keyId(): string | undefined { + return process.env.RAZORPAY_KEY_ID; + }, + get keySecret(): string | undefined { + return process.env.RAZORPAY_KEY_SECRET; + }, + get webhookSecret(): string | undefined { + return process.env.RAZORPAY_WEBHOOK_SECRET; + }, +}; + +export const authEnvs = { + get accessTokenExpiry(): string { + return process.env.ACCESS_TOKEN_EXPIRY ?? '30m'; + }, + get refreshTokenExpiry(): string { + return process.env.REFRESH_TOKEN_EXPIRY ?? '7d'; + }, + get accessTokenSecret(): string | undefined { + return process.env.JWT_ACCESS_SECRET; + }, + get refreshTokenSecret(): string | undefined { + return process.env.JWT_REFRESH_SECRET; + }, +}; + +export const swaggerConfig = { + get user(): string | undefined { + return process.env.SWAGGER_USER; + }, + get pass(): string | undefined { + return process.env.SWAGGER_PASS; + }, +}; diff --git a/server/src/constants/payment.constants.ts b/server/src/constants/payment.constants.ts new file mode 100644 index 0000000000..59656e58e9 --- /dev/null +++ b/server/src/constants/payment.constants.ts @@ -0,0 +1,6 @@ +export const PaymentStatus = { + PENDING: 'pending', + PAID: 'paid', + REFUNDED: 'refunded', +} as const; +export type PaymentStatusType = (typeof PaymentStatus)[keyof typeof PaymentStatus]; diff --git a/server/src/constants/permissions.ts b/server/src/constants/permissions.ts new file mode 100644 index 0000000000..6bf0ff4198 --- /dev/null +++ b/server/src/constants/permissions.ts @@ -0,0 +1,101 @@ +export const ActionEnum = [ + 'create', + 'read', + 'update', + 'delete', + 'approve', + 'reject', + 'activate', + 'deactivate', +] as const; + +export const EntityEnum = [ + 'users', + 'venues', + 'bookings', + 'roles', + 'permissions', + 'payments', + 'reviews', + 'wishlist', +] as const; + +export type IAction = (typeof ActionEnum)[number]; +export type IEntity = (typeof EntityEnum)[number]; +export type IPermission = `${IAction}:${IEntity}`; + +export const PERMISSIONS = { + users: { + create: 'create:users' as IPermission, + read: 'read:users' as IPermission, + update: 'update:users' as IPermission, + delete: 'delete:users' as IPermission, + activate: 'activate:users' as IPermission, + deactivate: 'deactivate:users' as IPermission, + }, + venues: { + create: 'create:venues' as IPermission, + read: 'read:venues' as IPermission, + update: 'update:venues' as IPermission, + delete: 'delete:venues' as IPermission, + activate: 'activate:venues' as IPermission, + deactivate: 'deactivate:venues' as IPermission, + }, + bookings: { + create: 'create:bookings' as IPermission, + read: 'read:bookings' as IPermission, + update: 'update:bookings' as IPermission, + delete: 'delete:bookings' as IPermission, + approve: 'approve:bookings' as IPermission, + reject: 'reject:bookings' as IPermission, + activate: 'activate:bookings' as IPermission, + deactivate: 'deactivate:bookings' as IPermission, + }, + roles: { + create: 'create:roles' as IPermission, + read: 'read:roles' as IPermission, + update: 'update:roles' as IPermission, + delete: 'delete:roles' as IPermission, + activate: 'activate:roles' as IPermission, + deactivate: 'deactivate:roles' as IPermission, + }, + permissions: { + create: 'create:permissions' as IPermission, + read: 'read:permissions' as IPermission, + update: 'update:permissions' as IPermission, + delete: 'delete:permissions' as IPermission, + activate: 'activate:permissions' as IPermission, + deactivate: 'deactivate:permissions' as IPermission, + }, + payments: { + create: 'create:payments' as IPermission, + read: 'read:payments' as IPermission, + update: 'update:payments' as IPermission, + delete: 'delete:payments' as IPermission, + approve: 'approve:payments' as IPermission, + reject: 'reject:payments' as IPermission, + activate: 'activate:payments' as IPermission, + deactivate: 'deactivate:payments' as IPermission, + }, + reviews: { + create: 'create:reviews' as IPermission, + read: 'read:reviews' as IPermission, + update: 'update:reviews' as IPermission, + delete: 'delete:reviews' as IPermission, + }, + wishlist: { + create: 'create:wishlist' as IPermission, + read: 'read:wishlist' as IPermission, + delete: 'delete:wishlist' as IPermission, + }, +} as const; + +// Static reference map — lowest priority number = highest rank. +// Runtime inheritance is resolved dynamically via $graphLookup, not this map. +// Extend this when new roles are added to the DB. +export const ROLE_HIERARCHY: Record = { + superAdmin: ['superAdmin', 'admin', 'owner', 'user'], + admin: ['admin', 'owner', 'user'], + owner: ['owner', 'user'], + user: ['user'], +}; diff --git a/server/src/constants/venue.constants.ts b/server/src/constants/venue.constants.ts new file mode 100644 index 0000000000..20f8d36349 --- /dev/null +++ b/server/src/constants/venue.constants.ts @@ -0,0 +1,91 @@ +import type { IVenue } from '../modules/venue/venue.types'; + +export const VenueStatusEnum = [ + 'Draft', + 'PendingReview', + 'Approved', + 'Rejected', + 'Suspended', + 'Inactive', +] as const; + +export const ReviewIntent = { + CREATION: 'creation', + RESUBMISSION: 'resubmission', + VENUE_EDIT: 'venue_edit', + INACTIVITY_REQUEST: 'inactivity_request', + INACTIVITY_WITHDRAWAL: 'inactivity_withdrawal', + DELETION_REQUEST: 'deletion_request', +} as const; + +export type ReviewIntentType = (typeof ReviewIntent)[keyof typeof ReviewIntent]; + +export const INACTIVITY_COOLDOWN_DAYS = 15; +export const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; + +export const KERALA_DISTRICTS = [ + 'Thiruvananthapuram', + 'Kollam', + 'Pathanamthitta', + 'Alappuzha', + 'Kottayam', + 'Idukki', + 'Ernakulam', + 'Thrissur', + 'Palakkad', + 'Malappuram', + 'Kozhikode', + 'Wayanad', + 'Kannur', + 'Kasaragod', +] as const; + +export const VenueFields = [ + 'name', + 'description', + 'venueType', + 'address', + 'city', + 'district', + 'pincode', + 'googleMapsUrl', + 'spaceAttributes', + 'seatingConfigurations', + 'maxCapacity', + 'bookingType', + 'fixedPackages', + 'workingDays', + 'workingHours', + 'flexibleBooking', + 'pricing', + 'blockedTimes', + 'blockedDates', + 'amenities', + 'coverImage', + 'galleryImages', + 'contact', + 'cancellation', +] as const; + +// Required Fields for Submission + +export const SUBMISSION_REQUIRED_FIELDS: (keyof IVenue)[] = [ + 'name', + 'description', + 'venueType', + 'address', + 'city', + 'district', + 'pincode', + 'bookingType', + 'cancellation', + 'contact', + 'coverImage', +]; + +export const VENUE_CONSTANTS = { + MAX_SUBMISSION_ATTEMPTS: 10, + EDIT_WINDOW_DAYS: 30, + MAX_EXTENDED_DAYS: 120, + AUTO_SUSPEND_REASON: 'Auto-suspended: Owner did not resubmit within 30 days after rejection', +} as const; diff --git a/server/src/middlewares/auth.middleware.ts b/server/src/middlewares/auth.middleware.ts new file mode 100644 index 0000000000..5e09e980bc --- /dev/null +++ b/server/src/middlewares/auth.middleware.ts @@ -0,0 +1,242 @@ +import type { NextFunction, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import mongoose from 'mongoose'; +import type { RefreshTokenPayload, TokenPayload } from '../types/express'; +import { ResponseUtil } from '../utils/responseUtils'; +import * as authRepo from '../modules/auth/auth.repository'; +import { tokenVerifyOptions, revokeTokenFamily } from '../utils/tokenUtils'; +import { TokenRevocationReason } from '../constants/auth.constants'; +import crypto from 'crypto'; +import { logWarn, logError } from '../utils/logger'; + +export const verifyAccessToken = (req: Request, res: Response, next: NextFunction): void => { + try { + const accessToken = req.cookies.accessToken as string | undefined; + + if (!accessToken) { + logWarn('Access token missing', { path: req.path, method: req.method }); + ResponseUtil.unauthorized(res, 'Access token required'); + return; + } + + const accessSecret = process.env.JWT_ACCESS_SECRET; + if (!accessSecret) { + logError('JWT_ACCESS_SECRET not configured', { + module: 'auth.middleware.ts/verifyAccessToken', + path: req.path, + }); + ResponseUtil.internalServerError(res, 'Server configuration error'); + return; + } + + const decoded = jwt.verify(accessToken, accessSecret, tokenVerifyOptions) as TokenPayload; + + if (!decoded.id || !decoded.username || !decoded.email) { + logWarn('Invalid access token payload', { path: req.path }); + ResponseUtil.unauthorized(res, 'Invalid access token'); + return; + } + + req.user = { + userId: decoded.id, + username: decoded.username, + email: decoded.email, + role: {}, + }; + + next(); + } catch (error) { + const authError = error as Error; + logWarn('Access token verification failed', { + error: authError.message, + path: req.path, + }); + + if (authError.name === 'TokenExpiredError') { + ResponseUtil.unauthorized(res, 'Access token expired'); + return; + } + + ResponseUtil.unauthorized(res, 'Invalid access token'); + } +}; + +export const verifyAccessTokenOptional = ( + req: Request, + _res: Response, + next: NextFunction +): void => { + try { + const accessToken = req.cookies.accessToken as string | undefined; + + if (!accessToken) { + next(); + return; + } + + const accessSecret = process.env.JWT_ACCESS_SECRET; + if (!accessSecret) { + logError('JWT_ACCESS_SECRET not configured', { + module: 'auth.middleware.ts/verifyAccessTokenOptional', + path: req.path, + }); + next(); + return; + } + + const decoded = jwt.verify(accessToken, accessSecret, tokenVerifyOptions) as TokenPayload; + + if (decoded.id && decoded.username && decoded.email) { + req.user = { + userId: decoded.id, + username: decoded.username, + email: decoded.email, + role: {}, + }; + } + + next(); + } catch (error) { + const authError = error as Error; + logWarn('Optional access token verification failed', { + error: authError.message, + path: req.path, + }); + next(); + } +}; + +export const verifyRefreshToken = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const refreshToken = req.cookies.refreshToken as string | undefined; + + if (!refreshToken) { + logWarn('Refresh token missing', { path: req.path }); + ResponseUtil.unauthorized(res, 'Refresh token required'); + return; + } + + const refreshSecret = process.env.JWT_REFRESH_SECRET; + if (!refreshSecret) { + logError('JWT_REFRESH_SECRET not configured', { + module: 'auth.middleware.ts/verifyRefreshToken', + path: req.path, + }); + ResponseUtil.internalServerError(res, 'Server configuration error'); + return; + } + + const decoded = jwt.verify( + refreshToken, + refreshSecret, + tokenVerifyOptions + ) as RefreshTokenPayload; + + if (!decoded.id || !decoded.jti) { + logWarn('Invalid refresh token payload', { path: req.path }); + ResponseUtil.unauthorized(res, 'Invalid refresh token'); + return; + } + + const tokenHash = crypto.createHash('sha256').update(decoded.jti).digest('hex'); + + const storedToken = await authRepo.findRefreshTokenByHash( + tokenHash, + new mongoose.Types.ObjectId(decoded.id) + ); + + if (!storedToken) { + logWarn('Refresh token not found', { path: req.path }); + ResponseUtil.unauthorized(res, 'Invalid refresh token'); + return; + } + + if (storedToken.expiresAt < new Date()) { + logWarn('Refresh token expired', { path: req.path }); + ResponseUtil.unauthorized(res, 'Refresh token expired, please login again'); + return; + } + + // Reuse detection + if (!storedToken.active) { + logWarn('Refresh token reuse detected — revoking entire token family', { + path: req.path, + rootTokenId: storedToken.rootTokenId.toString(), + userId: decoded.id, + }); + + await revokeTokenFamily( + storedToken.rootTokenId, + TokenRevocationReason.SUSPICIOUS_ACTIVITY + ).catch((err: unknown) => { + const error = err as Error; + logError('Failed to revoke token family on reuse detection', { + module: 'auth.middleware.ts/verifyRefreshToken', + error: error.message, + rootTokenId: storedToken.rootTokenId.toString(), + }); + }); + + await authRepo + .deactivateSessionByRootTokenId(storedToken.rootTokenId.toString()) + .catch((err: unknown) => { + const error = err as Error; + logError('Failed to deactivate session on reuse detection', { + module: 'auth.middleware.ts/verifyRefreshToken', + error: error.message, + rootTokenId: storedToken.rootTokenId.toString(), + }); + }); + + ResponseUtil.unauthorized(res, 'Invalid refresh token'); + return; + } + + // Session validation + const session = await authRepo.findSessionByRootTokenId(storedToken.rootTokenId); + + if (!session) { + logWarn('Session not found for refresh token', { path: req.path }); + ResponseUtil.unauthorized(res, 'Invalid refresh token'); + return; + } + + if (!session.active) { + logWarn('Session is inactive', { path: req.path }); + ResponseUtil.unauthorized(res, 'Session has been terminated, please log in again'); + return; + } + + if (session.absoluteExpiresAt < new Date()) { + logWarn('Session absolute expiry reached', { path: req.path }); + ResponseUtil.unauthorized(res, 'Session expired, please log in again'); + return; + } + + req.token = { + decoded: { + id: decoded.id, + jti: decoded.jti, + }, + stored: storedToken, + }; + + next(); + } catch (error) { + const authError = error as Error; + logWarn('Refresh token verification failed', { + error: authError.message, + }); + + if (authError.name === 'TokenExpiredError') { + ResponseUtil.unauthorized(res, 'Refresh token expired'); + return; + } + + ResponseUtil.unauthorized(res, 'Invalid refresh token'); + } +}; diff --git a/server/src/middlewares/idempotency.middleware.ts b/server/src/middlewares/idempotency.middleware.ts new file mode 100644 index 0000000000..13542d090c --- /dev/null +++ b/server/src/middlewares/idempotency.middleware.ts @@ -0,0 +1,43 @@ +import type { NextFunction, Request, Response } from 'express'; +import { IdempotencyKeyModel } from '../models/idempotency-key.model'; +import { logWarn } from '../utils/logger'; + +export function idempotencyMiddleware() { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const key = req.headers['idempotency-key'] as string; + if (!key) { + next(); + return; + } + + try { + const existing = await IdempotencyKeyModel.findOne({ key }).lean().exec(); + if (existing) { + logWarn('Idempotency key replay', { key, path: req.path }); + res.status(existing.response.status).json(existing.response.body); + return; + } + + const originalJson = res.json.bind(res); + res.json = (async function (body: unknown): Promise { + if (res.statusCode < 500) { + try { + await IdempotencyKeyModel.create({ + key, + response: { status: res.statusCode, body }, + createdAt: new Date(), + }); + } catch (err: unknown) { + logWarn('Failed to cache idempotency key', { key, error: err }); + } + } + return originalJson(body); + } as unknown) as typeof res.json; + + next(); + } catch (err) { + logWarn('Idempotency middleware error', { key, error: err }); + next(); + } + }; +} diff --git a/server/src/middlewares/ownerTenant.middleware.ts b/server/src/middlewares/ownerTenant.middleware.ts new file mode 100644 index 0000000000..2472ca0a9e --- /dev/null +++ b/server/src/middlewares/ownerTenant.middleware.ts @@ -0,0 +1,31 @@ +import type { NextFunction, Request, Response } from 'express'; +import { requireOwnVenue } from '../modules/venue/venue.ownership'; +import { ResponseUtil } from '../utils/responseUtils'; + +export const ownerTenantMiddleware = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const venueId = req.params.venueId || (req.body as Record).venueId; + const userId = req.user?.userId; + + if (!userId) { + ResponseUtil.unauthorized(res); + return; + } + + if (!venueId) { + ResponseUtil.badRequest(res, 'Venue ID is required'); + return; + } + // This will throw NotFoundError (404) or ForbiddenError (403) if validation fails + const venue = await requireOwnVenue(venueId as string, userId); + Object.assign(req, { venue }); + + next(); + } catch (error) { + next(error); + } +}; diff --git a/server/src/middlewares/pagination.middleware.ts b/server/src/middlewares/pagination.middleware.ts new file mode 100644 index 0000000000..6839d9bfa7 --- /dev/null +++ b/server/src/middlewares/pagination.middleware.ts @@ -0,0 +1,51 @@ +import type { Request, Response, NextFunction } from 'express'; +import type { PaginationMiddlewareOptions } from '../types/pagination.types'; +import { parsePaginationParams } from '../utils/paginationUtils'; + +/** + * Factory that returns an Express middleware which: + * 1. Reads `page`, `limit`, `skip`, and `sort` from `req.query`. + * 2. Normalises and validates them via {@link parsePaginationParams}. + * 3. Attaches the result to `req.pagination`. + * + * Downstream controllers can then access `req.pagination` directly — it is + * always defined on routes that use this middleware. + * + * @example Basic usage (project defaults) + * ```ts + * router.get('/venues', paginationMiddleware(), controller.listVenues); + * ``` + * + * @example Custom limits + * ```ts + * router.get('/venues', paginationMiddleware({ defaultLimit: 10, maxLimit: 50 }), controller.listVenues); + * ``` + * + * @example Sort by a different field by default + * ```ts + * router.get('/venues', paginationMiddleware({ defaultSort: '-updatedAt' }), controller.listVenues); + * ``` + */ +export function paginationMiddleware(options?: PaginationMiddlewareOptions) { + return (req: Request, _res: Response, next: NextFunction): void => { + req.pagination = parsePaginationParams( + { + page: req.query.page as string | undefined, + limit: req.query.limit as string | undefined, + skip: req.query.skip as string | undefined, + sort: req.query.sort as string | undefined, + }, + options + ); + + next(); + }; +} + +/** + * Ready-to-use pagination middleware with the project's default settings. + * Equivalent to `paginationMiddleware()` with no arguments. + * + * Defaults: limit=20, maxLimit=200, sort=`-createdAt` + */ +export const defaultPaginationMiddleware = paginationMiddleware(); diff --git a/server/src/middlewares/rbac.middleware.ts b/server/src/middlewares/rbac.middleware.ts new file mode 100644 index 0000000000..9b2641a03e --- /dev/null +++ b/server/src/middlewares/rbac.middleware.ts @@ -0,0 +1,181 @@ +import type { NextFunction, Request, Response } from 'express'; +import { ResponseUtil } from '../utils/responseUtils'; +import { getUserRole } from '../services/roles.service'; +import { getPerms } from '../services/cache/permission-cache.service'; +import type { IPermission } from '../constants/permissions'; +import { ROLE_HIERARCHY } from '../constants/permissions'; +import { logWarn, logError } from '../utils/logger'; + +export const loadPermissions = async (req: Request, res: Response): Promise => { + const { user } = req; + + if (!user?.userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return false; + } + + if (user.role.permissions) return true; + + const { userId } = user; + + const userRole = + user.role.id && user.role.name + ? { roleId: user.role.id, roleName: user.role.name } + : await getUserRole(userId); + + if (!userRole) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return false; + } + + user.role.id = userRole.roleId; + user.role.name = userRole.roleName; + + const permissions = await getPerms(userRole.roleId, userRole.roleName); + + user.role.permissions = new Set(permissions); + user.role.isSuperAdmin = userRole.roleName === 'superAdmin'; + + return true; +}; + +export const requirePermission = (...required: IPermission[]) => { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const userId = req.user?.userId; + + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + try { + const loaded = await loadPermissions(req, res); + if (!loaded) return; + + // superAdmin always bypasses + if (req.user?.role.isSuperAdmin) { + next(); + return; + } + + const userPerms = req.user?.role.permissions; + + if (!userPerms) { + logWarn('requirePermission: user has no permissions', { userId, path: req.path }); + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const permitted = required.every((p) => userPerms.has(p)); + + if (!permitted) { + logWarn('requirePermission: access denied', { + userId, + required, + path: req.path, + method: req.method, + }); + ResponseUtil.forbidden(res, 'Forbidden'); + return; + } + + next(); + } catch (e) { + const error = e as Error; + logError('requirePermission: unexpected error', { + module: 'rbac.middleware.ts/requirePermission', + error: error.message, + userId, + }); + ResponseUtil.internalServerError(res, 'Server error'); + } + }; +}; + +export const requireSuperAdmin = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + const userId = req.user?.userId; + + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + try { + const loaded = await loadPermissions(req, res); + if (!loaded) return; + + if (!req.user?.role.isSuperAdmin) { + logWarn('requireSuperAdmin: access denied', { userId, path: req.path }); + ResponseUtil.forbidden(res, 'Forbidden'); + return; + } + + next(); + } catch (e) { + const error = e as Error; + logError('requireSuperAdmin: unexpected error', { + module: 'rbac.middleware.ts/requireSuperAdmin', + error: error.message, + userId, + }); + ResponseUtil.internalServerError(res, 'Server error'); + } +}; + +export const requireRole = (...allowedRoles: string[]) => { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const userId = req.user?.userId; + + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + try { + const loaded = await loadPermissions(req, res); + if (!loaded) return; + + const roleName = req.user?.role.name; + + if (!roleName) { + logWarn('requireRole: user has no role', { userId, path: req.path }); + ResponseUtil.forbidden(res, 'Forbidden'); + return; + } + + if (req.user?.role.isSuperAdmin) { + next(); + return; + } + + const userHierarchy = ROLE_HIERARCHY[roleName] ?? [roleName]; + const permitted = allowedRoles.some((r) => userHierarchy.includes(r)); + + if (!permitted) { + logWarn('requireRole: access denied', { + userId, + roleName, + allowedRoles, + path: req.path, + method: req.method, + }); + ResponseUtil.forbidden(res, 'Forbidden'); + return; + } + + next(); + } catch (e) { + const error = e as Error; + logError('requireRole: unexpected error', { + module: 'rbac.middleware.ts/requireRole', + error: error.message, + userId, + }); + ResponseUtil.internalServerError(res, 'Server error'); + } + }; +}; diff --git a/server/src/middlewares/validation.middleware.ts b/server/src/middlewares/validation.middleware.ts new file mode 100644 index 0000000000..b9cbfbf37d --- /dev/null +++ b/server/src/middlewares/validation.middleware.ts @@ -0,0 +1,107 @@ +import type { NextFunction, Request, Response } from 'express'; +import { z } from 'zod'; +import { ResponseUtil } from '../utils/responseUtils'; +import { logWarn, logError } from '../utils/logger'; + +export interface ValidationSchema { + body?: z.ZodType; + query?: z.ZodType; + params?: z.ZodType; +} + +const formatErrorLog = (error: z.ZodError): { path: string; message: string; code: string }[] => { + return error.issues.map((issues) => ({ + path: issues.path.join('.') || 'unknown', + message: issues.message, + code: issues.code, + })); +}; + +const formatErrorMessage = (error: z.ZodError): string => { + return error.issues + .map((issues) => { + const field = issues.path.join('.') || 'unknown'; + return `${field}: ${issues.message}`; + }) + .join(', '); +}; + +const validate = ( + schema: ValidationSchema +): ((req: Request, res: Response, next: NextFunction) => void) => { + return (req: Request, res: Response, next: NextFunction): void => { + try { + req.validated = req.validated ?? {}; + + if (schema.body) { + const bodyResult = schema.body.safeParse(req.body); + if (!bodyResult.success) { + const errorLog = + bodyResult.error instanceof z.ZodError + ? formatErrorLog(bodyResult.error) + : 'Invalid request body'; + const errorMessage = + bodyResult.error instanceof z.ZodError + ? formatErrorMessage(bodyResult.error) + : 'Invalid request body'; + logWarn('Request body validation failed', { errorLog }); + ResponseUtil.badRequest(res, errorMessage); + return; + } + req.body = bodyResult.data; + req.validated.body = bodyResult.data; + } + + if (schema.params) { + const paramsResult = schema.params.safeParse(req.params); + if (!paramsResult.success) { + const errorLog = + paramsResult.error instanceof z.ZodError + ? formatErrorLog(paramsResult.error) + : 'Invalid request parameters'; + const errorMessage = + paramsResult.error instanceof z.ZodError + ? formatErrorMessage(paramsResult.error) + : 'Invalid request parameters'; + logWarn('Request params validation failed', { errorLog }); + ResponseUtil.badRequest(res, errorMessage); + return; + } + req.validated.params = paramsResult.data; + } + + if (schema.query) { + const queryResult = schema.query.safeParse(req.query); + if (!queryResult.success) { + const errorLog = + queryResult.error instanceof z.ZodError + ? formatErrorLog(queryResult.error) + : 'Invalid query parameters'; + const errorMessage = + queryResult.error instanceof z.ZodError + ? formatErrorMessage(queryResult.error) + : 'Invalid query parameters'; + logWarn('Request query validation failed', { errorLog }); + ResponseUtil.badRequest(res, errorMessage); + return; + } + req.validated.query = queryResult.data; + } + + next(); + } catch (error) { + logError('Validation middleware error', { module: 'validation.middleware.ts', error }); + ResponseUtil.internalServerError(res, 'Validation error'); + } + }; +}; + +export const validateBody = ( + schema: z.ZodType +): ((req: Request, res: Response, next: NextFunction) => void) => validate({ body: schema }); +export const validateParams = ( + schema: z.ZodType +): ((req: Request, res: Response, next: NextFunction) => void) => validate({ params: schema }); +export const validateQuery = ( + schema: z.ZodType +): ((req: Request, res: Response, next: NextFunction) => void) => validate({ query: schema }); diff --git a/server/src/models/email-task.model.ts b/server/src/models/email-task.model.ts new file mode 100644 index 0000000000..79410c4865 --- /dev/null +++ b/server/src/models/email-task.model.ts @@ -0,0 +1,59 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; +import { + EmailIntent, + type EmailIntentType, + EmailTaskStatus, + type EmailTaskStatusType, +} from '../constants/email.constants'; + +export interface IEmailTask extends Document { + intent: EmailIntentType; + recipient: string; + subject: string; + metadata: Record; + status: EmailTaskStatusType; + workerId: string | null; + lockedAt: Date | null; + retryAfter: Date; + retries: number; + lastError: string | null; + deleteAt: Date; + createdAt: Date; + updatedAt: Date; +} + +const EmailTaskSchema = new Schema( + { + intent: { + type: String, + enum: Object.values(EmailIntent), + required: true, + }, + recipient: { type: String, required: true }, + subject: { type: String, required: true }, + metadata: { type: Schema.Types.Mixed, default: {} }, + status: { + type: String, + enum: Object.values(EmailTaskStatus), + default: EmailTaskStatus.PENDING, + }, + workerId: { type: String, default: null }, + lockedAt: { type: Date, default: null }, + retryAfter: { type: Date, required: true }, + retries: { type: Number, default: 0 }, + lastError: { type: String, default: null }, + deleteAt: { type: Date, required: true }, // TTL: completed → 15 min, pending/failed → 7 days + }, + { timestamps: true } +); + +// Compound index for fast polling +EmailTaskSchema.index({ status: 1, lockedAt: 1, retryAfter: 1 }); +EmailTaskSchema.index({ deleteAt: 1 }, { expireAfterSeconds: 0 }); + +export const EmailTaskModel = mongoose.model( + 'EmailTasks', + EmailTaskSchema, + 'EmailTasks' +); diff --git a/server/src/models/featured-venue.model.ts b/server/src/models/featured-venue.model.ts new file mode 100644 index 0000000000..166d30acbd --- /dev/null +++ b/server/src/models/featured-venue.model.ts @@ -0,0 +1,26 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +export interface IFeaturedVenue extends Document { + venueId: mongoose.Types.ObjectId; + expiresAt: Date | null; +} + +const featuredVenueSchema = new Schema( + { + venueId: { type: Schema.Types.ObjectId, ref: 'Venues', required: true, unique: true }, + expiresAt: { type: Date, default: null }, + }, + { timestamps: true } +); + +featuredVenueSchema.index( + { expiresAt: 1 }, + { expireAfterSeconds: 0, sparse: true, name: 'idx_featured_ttl' } +); + +export const FeaturedVenueModel = mongoose.model( + 'FeaturedVenues', + featuredVenueSchema, + 'FeaturedVenues' +); diff --git a/server/src/models/idempotency-key.model.ts b/server/src/models/idempotency-key.model.ts new file mode 100644 index 0000000000..7f304ad79a --- /dev/null +++ b/server/src/models/idempotency-key.model.ts @@ -0,0 +1,23 @@ +import mongoose, { Schema } from 'mongoose'; +import type { Document } from 'mongoose'; + +export interface IIdempotencyKey extends Document { + key: string; + response: { status: number; body: unknown }; + createdAt: Date; +} + +const IdempotencyKeySchema = new Schema({ + key: { type: String, required: true, unique: true, index: true }, + response: { + status: { type: Number, required: true }, + body: { type: Schema.Types.Mixed, required: true }, + }, + createdAt: { type: Date, default: Date.now, index: { expires: 86400 } }, +}); + +export const IdempotencyKeyModel = mongoose.model( + 'IdempotencyKeys', + IdempotencyKeySchema, + 'IdempotencyKeys' +); diff --git a/server/src/models/permission.model.ts b/server/src/models/permission.model.ts new file mode 100644 index 0000000000..85896fa4f3 --- /dev/null +++ b/server/src/models/permission.model.ts @@ -0,0 +1,32 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; +import { ActionEnum, EntityEnum } from '../constants/permissions'; +import type { IAction, IEntity } from '../constants/permissions'; + +export interface IPermission extends Document { + action: IAction; + entity: IEntity; + isSystem: boolean; + active: boolean; + deleted: boolean; +} + +const permissionSchema = new Schema( + { + action: { type: String, required: true, enum: ActionEnum as unknown as string[] }, + entity: { type: String, required: true, enum: EntityEnum as unknown as string[] }, + isSystem: { type: Boolean, default: false, immutable: true }, + active: { type: Boolean, default: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true } +); + +// Composite unique — one row per action/entity combination +permissionSchema.index({ action: 1, entity: 1 }, { unique: true }); + +export const PermissionModel = mongoose.model( + 'Permissions', + permissionSchema, + 'Permissions' +); diff --git a/server/src/models/role-permission.model.ts b/server/src/models/role-permission.model.ts new file mode 100644 index 0000000000..ba8b8d3ab5 --- /dev/null +++ b/server/src/models/role-permission.model.ts @@ -0,0 +1,28 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +export interface IRolePermission extends Document { + roleId: mongoose.Types.ObjectId; + permissionId: mongoose.Types.ObjectId; + active: boolean; + deleted: boolean; +} + +const rolePermissionSchema = new Schema( + { + roleId: { type: Schema.Types.ObjectId, ref: 'Roles', required: true }, + permissionId: { type: Schema.Types.ObjectId, ref: 'Permissions', required: true }, + active: { type: Boolean, default: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true } +); + +// One grant per role/permission pair +rolePermissionSchema.index({ roleId: 1, permissionId: 1 }, { unique: true }); + +export const RolePermissionModel = mongoose.model( + 'RolePermissions', + rolePermissionSchema, + 'RolePermissions' +); diff --git a/server/src/models/role.model.ts b/server/src/models/role.model.ts new file mode 100644 index 0000000000..8a978d9e23 --- /dev/null +++ b/server/src/models/role.model.ts @@ -0,0 +1,37 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +// Kept open-ended so new roles can be inserted into the DB without a schema change. +// The enum below reflects the four system roles shipped at launch. +export const RoleEnum = ['user', 'owner', 'admin', 'superAdmin'] as const; +export type RoleName = (typeof RoleEnum)[number]; + +export interface IRole extends Document { + name: string; // string (not narrowed to RoleEnum) to allow future roles without a migration + displayName: string; + description?: string; + isSystem: boolean; // true for the four built-in roles + parentRole: mongoose.Types.ObjectId | null; // drives $graphLookup inheritance + priority: number; // lower = higher rank; superAdmin=1, admin=20, owner=50, user=100 + active: boolean; + deleted: boolean; +} + +const roleSchema = new Schema( + { + name: { type: String, required: true, unique: true }, + displayName: { type: String, required: true }, + description: { type: String, default: '' }, + isSystem: { type: Boolean, default: false, immutable: true }, + parentRole: { type: Schema.Types.ObjectId, ref: 'Roles', default: null }, + priority: { type: Number, required: true }, + active: { type: Boolean, default: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true } +); + +roleSchema.index({ name: 1, _id: 1 }); +roleSchema.index({ priority: 1 }); + +export const RoleModel = mongoose.model('Roles', roleSchema, 'Roles'); diff --git a/server/src/models/user-role.model.ts b/server/src/models/user-role.model.ts new file mode 100644 index 0000000000..2c23113b0c --- /dev/null +++ b/server/src/models/user-role.model.ts @@ -0,0 +1,26 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +// Join table: one active row per user. +// A user is promoted to owner/admin by upserting a new row (or updating roleId). +// No role field on the User document — roles live here exclusively. +export interface IUserRole extends Document { + userId: mongoose.Types.ObjectId; + roleId: mongoose.Types.ObjectId; + active: boolean; + deleted: boolean; +} + +const userRoleSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + roleId: { type: Schema.Types.ObjectId, ref: 'Roles', required: true }, + active: { type: Boolean, default: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true } +); + +userRoleSchema.index({ userId: 1, roleId: 1 }, { unique: true }); + +export const UserRoleModel = mongoose.model('UserRoles', userRoleSchema, 'UserRoles'); diff --git a/server/src/modules/auth/auth.controller.ts b/server/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000000..fea7477cbc --- /dev/null +++ b/server/src/modules/auth/auth.controller.ts @@ -0,0 +1,182 @@ +import { handleError } from '../../utils/errors'; +import { ResponseUtil } from '../../utils/responseUtils'; +import { setTokenCookies, clearTokenCookies } from '../../utils/tokenUtils'; +import * as service from './auth.service'; +import * as validator from './auth.validator'; +import type * as authScheme from './auth.validator'; +import type { Request, Response } from 'express'; +import type { z } from 'zod'; + +export const register = async (req: Request, res: Response): Promise => { + try { + const dto = req.validated?.body as z.infer; + const userId = await service.registerUser(dto); + + ResponseUtil.created(res, 'User registered successfully! Please login.', { userId }); + } catch (e) { + handleError(res, e, 'register'); + } +}; + +export const login = async (req: Request, res: Response): Promise => { + try { + const dto = req.validated?.body as z.infer; + const ip = req.ip ?? 'unknown'; + const userAgent = req.get('user-agent') ?? 'unknown'; + + const result = await service.loginUser(dto, ip, userAgent); + + setTokenCookies(res, result.accessToken, result.refreshToken); + + ResponseUtil.success(res, 'User logged in successfully', { + userId: result.userId, + username: result.username, + email: result.email, + }); + } catch (e) { + handleError(res, e, 'login'); + } +}; + +export const refreshToken = async (req: Request, res: Response): Promise => { + try { + const decodedToken = req.token?.decoded; + const storedToken = req.token?.stored; + + const result = await service.rotateRefreshToken(storedToken, decodedToken); + + setTokenCookies(res, result.accessToken, result.refreshToken); + + ResponseUtil.success(res, 'Refresh token generated successfully', { + userId: result.userId, + username: result.username, + email: result.email, + }); + } catch (e) { + handleError(res, e, 'refreshToken'); + } +}; + +export const logout = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const storedToken = req.token?.stored; + await service.logoutUser(userId, storedToken); + + clearTokenCookies(res); + + ResponseUtil.success(res, 'Logout successful'); + } catch (e) { + handleError(res, e, 'logout'); + } +}; + +export const forgotPassword = async (req: Request, res: Response): Promise => { + try { + const dto = req.validated?.body as z.infer; + const ip = req.ip ?? 'unknown'; + const userAgent = req.get('user-agent') ?? 'unknown'; + + await service.processForgotPassword(dto, ip, userAgent); + + ResponseUtil.success(res, 'If an account exists, a reset link has been sent.'); + } catch (e) { + handleError(res, e, 'forgotPassword'); + } +}; + +export const resetPassword = async (req: Request, res: Response): Promise => { + try { + const dto = req.validated?.body as z.infer; + + const ip = req.ip ?? 'unknown'; + const userAgent = req.get('user-agent') ?? 'unknown'; + + await service.processResetPassword(dto, ip, userAgent); + + ResponseUtil.success(res, 'Password reset successful. Please log in with your new password.'); + } catch (e) { + handleError(res, e, 'resetPassword'); + } +}; + +export const changePassword = async (req: Request, res: Response): Promise => { + try { + const validatedData = validator.changePasswordSchema.parse(req.body); + + if (!req.user?.userId) { + ResponseUtil.unauthorized(res, 'User not authenticated'); + return; + } + + await service.changePassword( + req.user.userId, + validatedData, + req.ip ?? req.socket.remoteAddress ?? 'unknown', + req.headers['user-agent'] ?? 'unknown' + ); + + ResponseUtil.success(res, 'Password changed successfully.'); + } catch (e) { + handleError(res, e, 'changePassword'); + } +}; + +export const listSessions = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + const rootTokenId = req.token?.stored.rootTokenId; + + if (!userId || !rootTokenId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const sessions = await service.listSessions(userId, rootTokenId); + ResponseUtil.success(res, 'Sessions retrieved successfully', sessions); + } catch (e) { + handleError(res, e, 'listSessions'); + } +}; + +export const revokeSession = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + const rootTokenId = req.token?.stored.rootTokenId; + + if (!userId || !rootTokenId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const { sessionId } = req.validated?.params as z.infer; + await service.revokeSession(userId, rootTokenId, sessionId); + + ResponseUtil.success(res, 'Device signed out successfully'); + } catch (e) { + handleError(res, e, 'revokeSession'); + } +}; + +export const revokeAllOtherSessions = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + const rootTokenId = req.token?.stored.rootTokenId; + + if (!userId || !rootTokenId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const result = await service.revokeAllOtherSessions(userId, rootTokenId); + ResponseUtil.success(res, 'Other devices signed out successfully', result); + } catch (e) { + handleError(res, e, 'revokeAllOtherSessions'); + } +}; diff --git a/server/src/modules/auth/auth.repository.ts b/server/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000000..a1490e1844 --- /dev/null +++ b/server/src/modules/auth/auth.repository.ts @@ -0,0 +1,375 @@ +import type mongoose from 'mongoose'; +import { PasswordResetRequestModel } from './models/password-reset-request.model'; +import { PasswordResetTokenModel } from './models/password-reset-token.model'; +import { RefreshTokenModel } from './models/refresh-token.model'; +import type { IRefreshToken } from './models/refresh-token.model'; +import { SessionModel } from './models/session.model'; +import type { ISession } from './models/session.model'; +import { EmailTaskModel } from '../../models/email-task.model'; +import { RoleModel } from '../../models/role.model'; +import { UserRoleModel } from '../../models/user-role.model'; +import type { TokenRevocationReasonType } from '../../constants/auth.constants'; + +export async function findDefaultRole(): Promise { + return RoleModel.findOne({ name: 'user', active: true, deleted: false }).lean().exec(); +} + +export async function assignRoleToUser( + userId: mongoose.Types.ObjectId, + roleId: mongoose.Types.ObjectId, + session?: mongoose.ClientSession +): Promise { + await UserRoleModel.updateOne( + { userId, roleId }, + { + $set: { + userId, + roleId, + active: true, + deleted: false, + }, + }, + { upsert: true, session } + ).exec(); +} + +export async function revokePasswordResetTokensOnLogin( + userId: mongoose.Types.ObjectId, + revocationReason: TokenRevocationReasonType, + session?: mongoose.ClientSession +): Promise { + await PasswordResetTokenModel.updateMany( + { userId, active: true }, + { + $set: { + active: false, + revokedAt: new Date(), + revokedReason: revocationReason, + }, + }, + { session } + ).exec(); +} + +export async function createSession( + userId: mongoose.Types.ObjectId, + rootTokenId: string, + absoluteExpiresAt: Date, + ipAddress: string, + userAgent: string, + session?: mongoose.ClientSession +): Promise { + await SessionModel.create( + [ + { + userId, + rootTokenId, + absoluteExpiresAt, + lastLogin: new Date(), + ipAddress, + userAgent, + }, + ], + { session } + ); +} + +export async function deactivateSessionByRootTokenId( + rootTokenId: string, + session?: mongoose.ClientSession +): Promise { + await SessionModel.findOneAndUpdate( + { rootTokenId }, + { $set: { active: false } }, + { session } + ).exec(); +} + +export async function getRateLimitDataForPasswordReset( + emailHash: string, + oneHourAgo: Date +): Promise<{ + totalCount: number; + emailCount: number; + lastEmailAt: Date | null; +}> { + const [rateLimitData] = await PasswordResetRequestModel.aggregate<{ + totalRequests: ({ n: number } | undefined)[]; + emailsSent: ({ n: number } | undefined)[]; + lastEmailSent: ({ createdAt: Date } | undefined)[]; + }>([ + { $match: { emailHash, createdAt: { $gte: oneHourAgo } } }, + { + $facet: { + totalRequests: [{ $count: 'n' }], + emailsSent: [{ $match: { emailSent: true } }, { $count: 'n' }], + lastEmailSent: [ + { $match: { emailSent: true } }, + { $sort: { createdAt: -1 } }, + { $limit: 1 }, + { $project: { createdAt: 1 } }, + ], + }, + }, + ]).exec(); + + const totalCount = rateLimitData.totalRequests[0]?.n ?? 0; + const emailCount = rateLimitData.emailsSent[0]?.n ?? 0; + const lastEmailAt = rateLimitData.lastEmailSent[0]?.createdAt ?? null; + + return { totalCount, emailCount, lastEmailAt }; +} + +export async function createPasswordResetRequest( + data: { + emailHash: string; + ip: string; + userAgent: string; + emailSent: boolean; + }, + session?: mongoose.ClientSession +): Promise { + await PasswordResetRequestModel.create([data], { session }); +} + +export async function cleanupExcessPasswordResetTokens( + userId: mongoose.Types.ObjectId, + maxActiveTokens: number, + session?: mongoose.ClientSession +): Promise { + const activeTokens = await PasswordResetTokenModel.find( + { + userId, + used: false, + active: true, + deleted: false, + expiresAt: { $gt: new Date() }, + }, + { _id: 1 }, + { sort: { createdAt: 1 } } + ) + .session(session ?? null) + .exec(); + + if (activeTokens.length >= maxActiveTokens) { + const excess = activeTokens.slice(0, activeTokens.length - maxActiveTokens + 1); + await PasswordResetTokenModel.updateMany( + { _id: { $in: excess.map((t) => t._id) } }, + { $set: { active: false, deleted: true } }, + { session } + ).exec(); + } +} + +export async function createPasswordResetToken( + data: { + userId: mongoose.Types.ObjectId; + tokenHash: string; + expiresAt: Date; + requestIp: string; + userAgent: string; + }, + session?: mongoose.ClientSession +): Promise { + await PasswordResetTokenModel.create([data], { session }); +} + +export async function createEmailTask( + data: { + intent: + | 'password_reset' + | 'account_notification' + | 'email_verification' + | 'security_alert' + | 'welcome'; + recipient: string; + subject: string; + metadata: Record; + }, + session?: mongoose.ClientSession +): Promise { + const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + await EmailTaskModel.create( + [{ ...data, retryAfter: new Date(), deleteAt: new Date(Date.now() + SEVEN_DAYS_MS) }], + { session } + ); +} + +export async function markPasswordResetTokenAsUsed( + tokenHash: string, + session?: mongoose.ClientSession +): Promise<(mongoose.Document & { userId: mongoose.Types.ObjectId; expiresAt: Date }) | null> { + return PasswordResetTokenModel.findOneAndUpdate( + { tokenHash, active: true, used: false, deleted: false, expiresAt: { $gt: new Date() } }, + { $set: { active: false, used: true, usedAt: new Date() } }, + { returnDocument: 'after', session: session ?? null } + ).exec(); +} + +export async function revokeAllOtherPasswordResetTokens( + userId: mongoose.Types.ObjectId, + excludeTokenId: mongoose.Types.ObjectId, + revocationReason: TokenRevocationReasonType, + session?: mongoose.ClientSession +): Promise { + await PasswordResetTokenModel.updateMany( + { userId, _id: { $ne: excludeTokenId }, active: true, used: false, deleted: false }, + { + $set: { + active: false, + revokedAt: new Date(), + revokedReason: revocationReason, + }, + }, + { session } + ).exec(); +} + +export async function revokeActivePasswordResetTokens( + userId: mongoose.Types.ObjectId, + excludeTokenId: mongoose.Types.ObjectId, + revocationReason: TokenRevocationReasonType, + session?: mongoose.ClientSession +): Promise { + await PasswordResetTokenModel.updateMany( + { userId, _id: { $ne: excludeTokenId }, active: true }, + { + $set: { + active: false, + revokedAt: new Date(), + revokedReason: revocationReason, + }, + }, + { session } + ).exec(); +} + +export async function revokeAllRefreshTokensForUser( + userId: mongoose.Types.ObjectId, + revocationReason: TokenRevocationReasonType, + session?: mongoose.ClientSession +): Promise { + await RefreshTokenModel.updateMany( + { userId, active: true }, + { + $set: { + active: false, + revokedAt: new Date(), + revokedReason: revocationReason, + }, + }, + { session } + ).exec(); +} + +export async function deactivateAllSessionsForUser( + userId: mongoose.Types.ObjectId, + session?: mongoose.ClientSession +): Promise { + await SessionModel.updateMany( + { userId, active: true }, + { $set: { active: false } }, + { session } + ).exec(); +} + +export async function findRefreshTokenByHash( + tokenHash: string, + userId: mongoose.Types.ObjectId +): Promise { + return RefreshTokenModel.findOne({ + tokenHash, + userId, + deleted: false, + }).exec(); +} + +export async function findSessionByRootTokenId( + rootTokenId: mongoose.Types.ObjectId +): Promise { + return SessionModel.findOne({ + rootTokenId, + deleted: false, + }).exec(); +} + +export async function getActiveSessionsForUser( + userId: mongoose.Types.ObjectId | string +): Promise { + return SessionModel.find({ userId, active: true, deleted: false }).sort({ createdAt: -1 }).exec(); +} + +export async function findActiveSessionByIdForUser( + sessionId: mongoose.Types.ObjectId | string, + userId: mongoose.Types.ObjectId | string +): Promise { + return SessionModel.findOne({ + _id: sessionId, + userId, + active: true, + deleted: false, + }).exec(); +} + +export async function deactivateSessionById( + sessionId: mongoose.Types.ObjectId | string, + userId: mongoose.Types.ObjectId | string, + session?: mongoose.ClientSession +): Promise { + await SessionModel.updateOne( + { _id: sessionId, userId, active: true }, + { $set: { active: false } }, + { session } + ).exec(); +} + +export async function createRefreshToken( + data: { + _id: mongoose.Types.ObjectId; + userId: mongoose.Types.ObjectId; + tokenHash: string; + rootTokenId: mongoose.Types.ObjectId; + parentTokenId: mongoose.Types.ObjectId | null; + expiresAt: Date; + }, + session?: mongoose.ClientSession +): Promise { + await RefreshTokenModel.create([data], { session }); +} + +export async function revokeRefreshToken( + tokenHash: string, + reason: TokenRevocationReasonType, + session?: mongoose.ClientSession +): Promise { + await RefreshTokenModel.updateOne( + { tokenHash, active: true, isUsed: false }, + { + $set: { + active: false, + isUsed: true, + revokedAt: new Date(), + revokedReason: reason, + }, + }, + { session } + ).exec(); +} + +export async function revokeTokenFamily( + rootTokenId: mongoose.Types.ObjectId, + reason: TokenRevocationReasonType, + session?: mongoose.ClientSession +): Promise { + await RefreshTokenModel.updateMany( + { rootTokenId, active: true }, + { + $set: { + active: false, + revokedAt: new Date(), + revokedReason: reason, + }, + }, + { session } + ).exec(); +} diff --git a/server/src/modules/auth/auth.router.ts b/server/src/modules/auth/auth.router.ts new file mode 100644 index 0000000000..f9d0a6f9f6 --- /dev/null +++ b/server/src/modules/auth/auth.router.ts @@ -0,0 +1,317 @@ +import { Router } from 'express'; +import * as controller from './auth.controller'; +import * as authValidator from './auth.validator'; +import { validateBody, validateParams } from '../../middlewares/validation.middleware'; +import { verifyAccessToken, verifyRefreshToken } from '../../middlewares/auth.middleware'; +import rateLimit from 'express-rate-limit'; + +const router: Router = Router(); + +const secondaryRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + message: 'Too many requests from this IP. Please try again after 15 minutes.', + standardHeaders: true, + legacyHeaders: false, +}); + +const loginRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + message: 'Too many login attempts from this IP. Please try again after 15 minutes.', + standardHeaders: true, + legacyHeaders: false, +}); + +/** + * @openapi + * /auth/register: + * post: + * tags: [Auth] + * summary: Register a new user + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [username, email, password] + * properties: + * username: + * type: string + * minLength: 3 + * maxLength: 30 + * example: johndoe + * email: + * type: string + * format: email + * example: john@example.com + * password: + * type: string + * minLength: 8 + * example: SecurePass@1 + * responses: + * 201: + * description: User registered successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 400: + * description: Validation error + * 409: + * description: Username or email already taken + */ +router.route('/register').post(validateBody(authValidator.registerSchema), controller.register); + +/** + * @openapi + * /auth/login: + * post: + * tags: [Auth] + * summary: Login with email/username and password + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [password] + * properties: + * username: + * type: string + * example: johndoe + * email: + * type: string + * format: email + * example: john@example.com + * password: + * type: string + * example: SecurePass@1 + * responses: + * 200: + * description: Login successful + * 400: + * description: Validation error or missing credentials + * 401: + * description: Invalid credentials + * 429: + * description: Too many requests + */ +router + .route('/login') + .post(loginRateLimiter, validateBody(authValidator.loginSchema), controller.login); + +/** + * @openapi + * /auth/refresh: + * post: + * tags: [Auth] + * summary: Refresh access token using refresh token cookie + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: New access token issued + * 401: + * description: Missing, invalid, or expired refresh token + */ +router.route('/refresh').post(verifyRefreshToken, controller.refreshToken); + +/** + * @openapi + * /auth/logout: + * post: + * tags: [Auth] + * summary: Logout and revoke the current session + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * responses: + * 200: + * description: Logged out successfully + * 401: + * description: Not authenticated + */ +router.route('/logout').post(verifyAccessToken, verifyRefreshToken, controller.logout); + +/** + * @openapi + * /auth/forgot-password: + * post: + * tags: [Auth] + * summary: Send a password reset email + * description: Rate-limited to 5 requests per 15 minutes per IP. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * format: email + * example: john@example.com + * responses: + * 200: + * description: Reset email sent if account exists + * 400: + * description: Invalid email format + * 429: + * description: Too many requests + */ +router + .route('/forgot-password') + .post( + secondaryRateLimiter, + validateBody(authValidator.forgotPasswordSchema), + controller.forgotPassword + ); + +/** + * @openapi + * /auth/reset-password: + * post: + * tags: [Auth] + * summary: Reset password using a valid reset token + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [token, password] + * properties: + * token: + * type: string + * description: Token received in the reset email + * example: abc123xyz + * password: + * type: string + * minLength: 8 + * example: NewSecurePass@1 + * responses: + * 200: + * description: Password reset successfully + * 400: + * description: Invalid or expired token, or validation error + */ +router + .route('/reset-password') + .post(validateBody(authValidator.resetPasswordSchema), controller.resetPassword); + +/** + * @openapi + * /auth/change-password: + * patch: + * tags: [Auth] + * summary: Change user password + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [oldPassword, newPassword] + * properties: + * oldPassword: + * type: string + * newPassword: + * type: string + * format: password + * responses: + * 200: + * description: Password changed successfully + * 400: + * description: Invalid input or incorrect old password + * 401: + * description: Unauthorized + */ +router.route('/change-password').patch(verifyAccessToken, controller.changePassword); + +/** + * @openapi + * /auth/sessions: + * get: + * tags: [Auth] + * summary: List the authenticated user's active sessions/devices + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * responses: + * 200: + * description: Array of active sessions, each flagged with isCurrent + * 401: + * description: Not authenticated + */ +router.route('/sessions').get(verifyAccessToken, verifyRefreshToken, controller.listSessions); + +/** + * @openapi + * /auth/sessions/logout-others: + * post: + * tags: [Auth] + * summary: Sign out every other active session/device + * description: | + * Blocked with a 403 if the current session is less than 48 hours old and + * any other active session predates it — prevents a newly-added device + * from locking out established devices. + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * responses: + * 200: + * description: Other sessions signed out successfully + * 401: + * description: Not authenticated + * 403: + * description: Current device is too new to sign out older devices + */ +router + .route('/sessions/logout-others') + .post(verifyAccessToken, verifyRefreshToken, controller.revokeAllOtherSessions); + +/** + * @openapi + * /auth/sessions/{sessionId}: + * delete: + * tags: [Auth] + * summary: Sign out one specific session/device + * description: | + * Blocked with a 403 if the current session is less than 48 hours old and + * the target session predates it. + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Device signed out successfully + * 400: + * description: Cannot revoke the current session this way + * 401: + * description: Not authenticated + * 403: + * description: Current device is too new to sign out older devices + * 404: + * description: Session not found + */ +router + .route('/sessions/:sessionId') + .delete( + verifyAccessToken, + verifyRefreshToken, + validateParams(authValidator.sessionIdParamSchema), + controller.revokeSession + ); + +export { router as authRouter }; diff --git a/server/src/modules/auth/auth.service.ts b/server/src/modules/auth/auth.service.ts new file mode 100644 index 0000000000..6c7fdc1bf6 --- /dev/null +++ b/server/src/modules/auth/auth.service.ts @@ -0,0 +1,560 @@ +import crypto from 'crypto'; +import * as bcrypt from 'bcrypt'; +import type { z } from 'zod'; +import { authEnvs } from '../../constants/env'; +import { AuthConstants, TokenRevocationReason } from '../../constants/auth.constants'; +import * as authRepo from './auth.repository'; +import * as userRepo from '../user/user.repository'; +import type * as authScheme from './auth.validator'; +import { AppError, NotFoundError, ForbiddenError } from '../../utils/errors'; +import { logError, logWarn, logInfo } from '../../utils/logger'; +import { runInTransaction } from '../../utils/dbUtils'; +import mongoose from 'mongoose'; +import type * as validator from './auth.validator'; +import { + generateAccessToken, + generateRefreshToken, + revokeRefreshToken, +} from '../../utils/tokenUtils'; +import type { DecodedToken, StoredToken } from '../../types/express'; + +export class UnauthorizedError extends AppError { + constructor(message = 'Unauthorized') { + super(message, 401, 'UNAUTHORIZED'); + } +} + +export class RateLimitError extends AppError { + constructor(message = 'Too many requests. Please try again later') { + super(message, 429, 'RATE_LIMIT_EXCEEDED'); + } +} + +export class BadRequestError extends AppError { + constructor(message = 'Bad Request') { + super(message, 400, 'BAD_REQUEST'); + } +} + +export async function registerUser( + dto: z.infer +): Promise { + const { username, email, password } = dto; + + const existingUser = await userRepo.findUserByUsernameOrEmail(username, email); + if (existingUser) { + logWarn('Registration attempt with existing user', { username, email }); + throw new BadRequestError( + 'User already exists! please login instead, or choose a different username/email' + ); + } + + const defaultRole = await authRepo.findDefaultRole(); + if (!defaultRole) { + logError('register: default "user" role missing from system configs', { + module: 'auth.service.ts/registerUser', + }); + throw new AppError( + 'System initialization Error, Pls contact admin...', + 500, + 'INTERNAL_SERVER_ERROR' + ); + } + + let createdUserId = ''; + + await runInTransaction(async (session) => { + const hashedPassword = await bcrypt.hash(password, 12); + + const newUser = await userRepo.createUser( + { username, email, passwordHash: hashedPassword }, + session + ); + + createdUserId = newUser._id.toString(); + + await authRepo.assignRoleToUser(newUser._id, defaultRole._id, session); + }); + + logInfo('New user registered successfully', { username, email }); + return createdUserId; +} + +export async function loginUser( + dto: z.infer, + ipAddress: string, + userAgent: string +): Promise<{ + userId: string; + username: string; + email: string; + accessToken: string; + refreshToken: string; +}> { + const identifier = dto.username ?? dto.email; + + logInfo('Login attempt', { credential: identifier }); + + if (!identifier || !dto.password) { + logWarn('Login attempt with missing credentials'); + throw new BadRequestError('Username/Email and password required'); + } + + const user = await userRepo.findActiveUserByIdentifierWithPassword(identifier); + + if (!user) { + logWarn('Login attempt with non-existent user', { credential: identifier }); + throw new UnauthorizedError('Invalid username or password'); + } + + const isPasswordValid = await bcrypt.compare(dto.password, user.password); + + if (!isPasswordValid) { + logWarn('Login attempt with invalid password', { credential: identifier }); + throw new UnauthorizedError('Invalid username or password'); + } + + if (!authEnvs.accessTokenSecret || !authEnvs.refreshTokenSecret) { + logError('JWT secrets not configured', { module: 'auth.service.ts/loginUser' }); + throw new AppError('Server configuration error', 500, 'INTERNAL_SERVER_ERROR'); + } + + const userId = user._id.toString(); + let refreshTokenStr = ''; + + await runInTransaction(async (session) => { + try { + await authRepo.revokePasswordResetTokensOnLogin( + user._id, + TokenRevocationReason.USER_LOGIN, + session + ); + } catch (err: unknown) { + logError('Failed to cleanup reset tokens on login', { + module: 'auth.service.ts/loginUser', + error: err instanceof Error ? err.message : String(err), + }); + } + + const result = await generateRefreshToken(userId, undefined, undefined, session); + refreshTokenStr = result.token; + + await authRepo.createSession( + user._id, + result.rootTokenId, + new Date(Date.now() + AuthConstants.SESSION_ABSOLUTE_EXPIRY_MS), + ipAddress, + userAgent, + session + ); + }); + + const accessTokenStr = generateAccessToken(userId, user.username, user.email); + + return { + userId, + username: user.username, + email: user.email, + accessToken: accessTokenStr, + refreshToken: refreshTokenStr, + }; +} + +export async function rotateRefreshToken( + storedToken: StoredToken | undefined, + decodedToken: DecodedToken | undefined +): Promise<{ + userId: string; + username: string; + email: string; + accessToken: string; + refreshToken: string; +}> { + if (!decodedToken || !storedToken) { + throw new UnauthorizedError('Invalid refresh token'); + } + + const userId = decodedToken.id; + const user = await userRepo.findActiveUserById(userId); + + if (!user) { + throw new UnauthorizedError('Invalid refresh token'); + } + + const newAccessToken = generateAccessToken(userId, user.username, user.email); + let newRefreshTokenStr = ''; + + await runInTransaction(async (session) => { + const result = await generateRefreshToken( + userId, + storedToken.rootTokenId.toString(), + storedToken._id.toString(), + session + ); + newRefreshTokenStr = result.token; + + await revokeRefreshToken(storedToken.tokenHash, TokenRevocationReason.TOKEN_ROTATION, session); + }); + + return { + userId, + username: user.username, + email: user.email, + accessToken: newAccessToken, + refreshToken: newRefreshTokenStr, + }; +} + +export interface SessionSummary { + id: string; + ipAddress: string; + userAgent: string; + lastLogin: Date; + createdAt: Date; + isCurrent: boolean; +} + +export async function listSessions( + userId: string, + currentRootTokenId: mongoose.Types.ObjectId +): Promise { + const sessions = await authRepo.getActiveSessionsForUser(userId); + + return sessions.map((s) => ({ + id: s._id.toString(), + ipAddress: s.ipAddress, + userAgent: s.userAgent, + lastLogin: s.lastLogin, + createdAt: s.createdAt, + isCurrent: s.rootTokenId.equals(currentRootTokenId), + })); +} + +function assertCanRevokeOlderSessions( + currentSession: { createdAt: Date }, + targetCreatedAts: Date[] +): void { + const currentAgeMs = Date.now() - currentSession.createdAt.getTime(); + const hasOlderTarget = targetCreatedAts.some((createdAt) => createdAt < currentSession.createdAt); + + if (currentAgeMs < AuthConstants.NEW_SESSION_REVOKE_LOCK_MS && hasOlderTarget) { + throw new ForbiddenError( + 'This device is too new to sign out older devices. This unlocks 48 hours after you sign in here.' + ); + } +} + +export async function revokeSession( + userId: string, + currentRootTokenId: mongoose.Types.ObjectId, + targetSessionId: string +): Promise { + const currentSession = await authRepo.findSessionByRootTokenId(currentRootTokenId); + if (!currentSession) { + throw new UnauthorizedError('Current session not found'); + } + + if (currentSession._id.toString() === targetSessionId) { + throw new BadRequestError('Use logout to sign out of this device'); + } + + const targetSession = await authRepo.findActiveSessionByIdForUser(targetSessionId, userId); + if (!targetSession) { + throw new NotFoundError('Session not found'); + } + + assertCanRevokeOlderSessions(currentSession, [targetSession.createdAt]); + + await runInTransaction(async (session) => { + await authRepo.revokeTokenFamily( + targetSession.rootTokenId, + TokenRevocationReason.USER_REVOKED, + session + ); + await authRepo.deactivateSessionById(targetSession._id, userId, session); + }); +} + +export async function revokeAllOtherSessions( + userId: string, + currentRootTokenId: mongoose.Types.ObjectId +): Promise<{ revokedCount: number }> { + const currentSession = await authRepo.findSessionByRootTokenId(currentRootTokenId); + if (!currentSession) { + throw new UnauthorizedError('Current session not found'); + } + + const otherSessions = (await authRepo.getActiveSessionsForUser(userId)).filter( + (s) => !s._id.equals(currentSession._id) + ); + + assertCanRevokeOlderSessions( + currentSession, + otherSessions.map((s) => s.createdAt) + ); + + await runInTransaction(async (session) => { + for (const s of otherSessions) { + await authRepo.revokeTokenFamily(s.rootTokenId, TokenRevocationReason.USER_REVOKED, session); + await authRepo.deactivateSessionById(s._id, userId, session); + } + }); + + return { revokedCount: otherSessions.length }; +} + +export async function logoutUser(userId: string, storedToken?: StoredToken): Promise { + if (storedToken) { + await runInTransaction(async (session) => { + await revokeRefreshToken(storedToken.tokenHash, TokenRevocationReason.USER_LOGOUT, session); + await authRepo.deactivateSessionByRootTokenId(storedToken.rootTokenId.toString(), session); + }); + } + logInfo('User logged out', { userId }); +} + +export async function processForgotPassword( + dto: z.infer, + ipAddress: string, + userAgent: string +): Promise { + const normalizedEmail = dto.email.trim().toLowerCase(); + const emailHash = crypto.createHash('sha256').update(normalizedEmail).digest('hex'); + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const { totalCount, emailCount, lastEmailAt } = await authRepo.getRateLimitDataForPasswordReset( + emailHash, + oneHourAgo + ); + + // Rate limit: max 20 requests per emailHash per hour + if (totalCount >= AuthConstants.MAX_REQUESTS_PER_HR) { + logWarn('forgot-password: request limit exceeded', { emailHash, ip: ipAddress, userAgent }); + throw new RateLimitError(); + } + + // Rate limit: max 5 emails per emailHash per hour + if (emailCount >= AuthConstants.MAX_EMAILS_PER_HR) { + logWarn('forgot-password: email send limit exceeded', { emailHash, ip: ipAddress, userAgent }); + throw new RateLimitError(); + } + + // Cooldown: 30 seconds between email sends for this emailHash + if (lastEmailAt) { + const msSinceLastEmail = Date.now() - lastEmailAt.getTime(); + if (msSinceLastEmail < AuthConstants.RESEND_COOLDOWN_MS) { + const secondsRemaining = Math.ceil( + (AuthConstants.RESEND_COOLDOWN_MS - msSinceLastEmail) / 1000 + ); + logWarn('forgot-password: cooldown active', { + emailHash, + ip: ipAddress, + userAgent, + secondsRemaining, + }); + throw new RateLimitError( + `Please wait ${secondsRemaining.toString()} seconds before resending reset link` + ); + } + } + + const user = await userRepo.findActiveUserByEmail(normalizedEmail); + if (!user) { + await authRepo.createPasswordResetRequest({ + emailHash, + ip: ipAddress, + userAgent, + emailSent: false, + }); + logWarn('forgot-password: user not found or inactive', { ip: ipAddress, userAgent }); + return; + } + + const rawToken = crypto.randomBytes(32).toString('hex'); + const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); + const expiresAt = new Date(Date.now() + AuthConstants.TOKEN_EXPIRY_MS); + + await runInTransaction(async (session) => { + await authRepo.cleanupExcessPasswordResetTokens( + user._id, + AuthConstants.MAX_ACTIVE_TOKENS, + session + ); + + await authRepo.createPasswordResetRequest( + { emailHash, ip: ipAddress, userAgent, emailSent: true }, + session + ); + + await authRepo.createPasswordResetToken( + { + userId: user._id, + tokenHash, + expiresAt, + requestIp: ipAddress, + userAgent, + }, + session + ); + + const frontendUrl = process.env.FRONTEND_URL ?? ''; + const resetLink = `${frontendUrl}/reset-password?token=${rawToken}`; + const appName = process.env.APP_NAME ?? 'BookMyVenue'; + + await authRepo.createEmailTask( + { + intent: 'password_reset', + recipient: user.email, + subject: `Reset your ${appName} password`, + metadata: { resetLink }, + }, + session + ); + }); + + logInfo('forgot-password: reset email successfully synced & queued', { + userId: user._id.toString(), + ip: ipAddress, + userAgent, + }); +} + +export async function processResetPassword( + dto: z.infer, + ipAddress: string, + userAgent: string +): Promise { + const { token, password } = dto; + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + + await runInTransaction(async (session) => { + const tokenRecord = await authRepo.markPasswordResetTokenAsUsed(tokenHash, session); + + if (!tokenRecord) { + logWarn('reset-password: token not found or already used', { ip: ipAddress, userAgent }); + throw new BadRequestError('Invalid or expired reset token'); + } + + const user = await userRepo.findActiveUserByIdWithPassword( + tokenRecord.userId.toString(), + session + ); + + if (!user) { + logWarn('reset-password: user not found or inactive', { + userId: tokenRecord.userId.toString(), + ip: ipAddress, + userAgent, + }); + throw new BadRequestError('Invalid or expired reset token'); + } + + const userId = user._id; + const isSamePassword = await bcrypt.compare(password, user.password); + + if (isSamePassword) { + await authRepo.revokeAllOtherPasswordResetTokens( + userId, + tokenRecord._id, + TokenRevocationReason.PASSWORD_REUSE_ATTEMPT, + session + ); + logWarn('reset-password: password reuse attempt', { + userId: userId.toString(), + ip: ipAddress, + userAgent, + }); + throw new BadRequestError('New password cannot be the same as your old password'); + } + + const hashedPassword = await bcrypt.hash(password, 12); + await userRepo.updateUserPassword(userId, hashedPassword, session); + + await authRepo.revokeActivePasswordResetTokens( + userId, + tokenRecord._id, + TokenRevocationReason.PASSWORD_CHANGED, + session + ); + + await authRepo.revokeAllRefreshTokensForUser( + userId, + TokenRevocationReason.PASSWORD_CHANGED, + session + ); + + await authRepo.deactivateAllSessionsForUser(userId, session); + + const appName = process.env.APP_NAME ?? 'BookMyVenue'; + await authRepo.createEmailTask( + { + intent: 'security_alert', + recipient: user.email, + subject: `Your ${appName} password was changed`, + metadata: {}, + }, + session + ); + + logInfo('reset-password: password reset successful', { + userId: userId.toString(), + ip: ipAddress, + userAgent, + }); + }); +} + +export async function changePassword( + userId: string, + dto: z.infer, + ipAddress: string, + userAgent: string +): Promise { + const session = await mongoose.startSession(); + + await session.withTransaction(async () => { + const user = await userRepo.findActiveUserByIdWithPassword(userId, session); + if (!user) { + throw new NotFoundError('User not found'); + } + + const isOldPasswordValid = await bcrypt.compare(dto.oldPassword, user.password); + if (!isOldPasswordValid) { + throw new BadRequestError('Incorrect old password'); + } + + const isSamePassword = await bcrypt.compare(dto.newPassword, user.password); + if (isSamePassword) { + throw new BadRequestError('New password cannot be the same as your old password'); + } + + const hashedPassword = await bcrypt.hash(dto.newPassword, 12); + await userRepo.updateUserPassword(userId, hashedPassword, session); + + await authRepo.revokeAllRefreshTokensForUser( + new mongoose.Types.ObjectId(userId), + TokenRevocationReason.PASSWORD_CHANGED, + session + ); + + await authRepo.deactivateAllSessionsForUser(new mongoose.Types.ObjectId(userId), session); + + const appName = process.env.APP_NAME ?? 'BookMyVenue'; + await authRepo.createEmailTask( + { + intent: 'security_alert', + recipient: user.email, + subject: `Your ${appName} password was changed`, + metadata: {}, + }, + session + ); + + logInfo('change-password: password changed successfully', { + userId, + ip: ipAddress, + userAgent, + }); + }); + await session.endSession(); +} diff --git a/server/src/modules/auth/auth.validator.ts b/server/src/modules/auth/auth.validator.ts new file mode 100644 index 0000000000..f484502b1d --- /dev/null +++ b/server/src/modules/auth/auth.validator.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +export const registerSchema = z.object({ + username: z.string().trim().min(3).max(30), + email: z.email('Invalid email Format'), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/, + 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character' + ) + .max(100), +}); + +export const loginSchema = z + .object({ + username: z.string().trim().min(3).max(30).optional(), + email: z.email('Invalid email Format').optional(), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!#%*?&])[A-Za-z\d@$!#%*?&]+$/, + 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character' + ) + .max(100), + }) + .refine((data) => data.username ?? data.email, { + message: 'Either username or email is required', + path: ['username', 'email'], + }); + +export const forgotPasswordSchema = z.object({ + email: z.email('Invalid email format'), +}); + +export const resetPasswordSchema = z.object({ + token: z.string().min(1, 'Token is required'), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/, + 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character' + ) + .max(100), +}); + +export const changePasswordSchema = z.object({ + oldPassword: z.string().min(1, 'Old password is required'), + newPassword: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/, + 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character' + ) + .max(100), +}); + +export const sessionIdParamSchema = z.object({ + sessionId: z + .string() + .trim() + .regex(/^[a-f\d]{24}$/i, 'Invalid session ID'), +}); diff --git a/server/src/modules/auth/models/password-reset-request.model.ts b/server/src/modules/auth/models/password-reset-request.model.ts new file mode 100644 index 0000000000..de6ae8b27d --- /dev/null +++ b/server/src/modules/auth/models/password-reset-request.model.ts @@ -0,0 +1,30 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +export interface IPasswordResetRequest extends Document { + emailHash: string; + ip: string; + userAgent: string; + emailSent: boolean; + createdAt: Date; + updatedAt: Date; +} + +const PasswordResetRequestSchema = new Schema( + { + emailHash: { type: String, required: true }, + ip: { type: String, required: true }, + userAgent: { type: String, required: true }, + emailSent: { type: Boolean, default: false }, + createdAt: { type: Date, default: Date.now, expires: 7200 }, + }, + { timestamps: true } +); + +PasswordResetRequestSchema.index({ emailHash: 1, createdAt: -1 }); + +export const PasswordResetRequestModel = mongoose.model( + 'PasswordResetRequests', + PasswordResetRequestSchema, + 'PasswordResetRequests' +); diff --git a/server/src/modules/auth/models/password-reset-token.model.ts b/server/src/modules/auth/models/password-reset-token.model.ts new file mode 100644 index 0000000000..2ec430bd1a --- /dev/null +++ b/server/src/modules/auth/models/password-reset-token.model.ts @@ -0,0 +1,47 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +export interface IPasswordResetToken extends Document { + userId: mongoose.Types.ObjectId; + tokenHash: string; + expiresAt: Date; + used: boolean; + usedAt: Date | null; + active: boolean; + deleted: boolean; + requestIp: string; + userAgent: string; + createdAt: Date; + updatedAt: Date; + revokedAt: Date | null; + revokedReason: string | null; +} + +const PasswordResetTokenSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + tokenHash: { type: String, required: true }, + expiresAt: { type: Date, required: true, index: { expires: 0 } }, + used: { type: Boolean, default: false }, + usedAt: { type: Date, default: null }, + active: { type: Boolean, default: true }, + deleted: { type: Boolean, default: false }, + requestIp: { type: String, required: true }, + userAgent: { type: String, required: true }, + revokedAt: { type: Date, default: null }, + revokedReason: { type: String, default: null }, + }, + { timestamps: true } +); + +// Fast lookup by hash during reset validation +PasswordResetTokenSchema.index({ tokenHash: 1 }, { unique: true }); + +// Bulk-invalidate all tokens for a user after successful reset +PasswordResetTokenSchema.index({ userId: 1 }); + +export const PasswordResetTokenModel = mongoose.model( + 'PasswordResetTokens', + PasswordResetTokenSchema, + 'PasswordResetTokens' +); diff --git a/server/src/modules/auth/models/refresh-token.model.ts b/server/src/modules/auth/models/refresh-token.model.ts new file mode 100644 index 0000000000..8c4cfd13f9 --- /dev/null +++ b/server/src/modules/auth/models/refresh-token.model.ts @@ -0,0 +1,43 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +export interface IRefreshToken extends Document { + userId: mongoose.Types.ObjectId; + tokenHash: string; + rootTokenId: mongoose.Types.ObjectId; + parentTokenId: mongoose.Types.ObjectId | null; + isUsed: boolean; + expiresAt: Date; + active: boolean; + deleted: boolean; + revokedAt: Date | null; + revokedReason: string | null; + createdAt: Date; + updatedAt: Date; +} + +const RefreshTokenSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + tokenHash: { type: String, required: true }, + rootTokenId: { type: Schema.Types.ObjectId, ref: 'RefreshTokens', required: true }, + parentTokenId: { type: Schema.Types.ObjectId, ref: 'RefreshTokens', default: null }, + isUsed: { type: Boolean, default: false }, + expiresAt: { type: Date, required: true, index: { expires: 0 } }, + active: { type: Boolean, default: true }, + deleted: { type: Boolean, default: false }, + revokedAt: { type: Date, default: null }, + revokedReason: { type: String, default: null }, + }, + { timestamps: true } +); + +RefreshTokenSchema.index({ tokenHash: 1 }, { unique: true }); +RefreshTokenSchema.index({ userId: 1, tokenHash: 1 }); +RefreshTokenSchema.index({ rootTokenId: 1 }); + +export const RefreshTokenModel = mongoose.model( + 'RefreshTokens', + RefreshTokenSchema, + 'RefreshTokens' +); diff --git a/server/src/modules/auth/models/session.model.ts b/server/src/modules/auth/models/session.model.ts new file mode 100644 index 0000000000..96c6192e6e --- /dev/null +++ b/server/src/modules/auth/models/session.model.ts @@ -0,0 +1,38 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +export interface ISession extends Document { + userId: mongoose.Types.ObjectId; + rootTokenId: mongoose.Types.ObjectId; + absoluteExpiresAt: Date; + lastLogin: Date; + ipAddress: string; + userAgent: string; + active: boolean; + deleted: boolean; + createdAt: Date; + updatedAt: Date; +} + +const SessionSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + rootTokenId: { + type: Schema.Types.ObjectId, + ref: 'RefreshTokens', + required: true, + unique: true, + }, + absoluteExpiresAt: { type: Date, required: true, index: { expires: 0 } }, + lastLogin: { type: Date, default: Date.now }, + ipAddress: { type: String, required: true }, + userAgent: { type: String, required: true }, + active: { type: Boolean, default: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true } +); + +SessionSchema.index({ userId: 1 }); + +export const SessionModel = mongoose.model('Sessions', SessionSchema, 'Sessions'); diff --git a/server/src/modules/availability/availability.controller.ts b/server/src/modules/availability/availability.controller.ts new file mode 100644 index 0000000000..04ad9b1285 --- /dev/null +++ b/server/src/modules/availability/availability.controller.ts @@ -0,0 +1,76 @@ +import type { Request, Response } from 'express'; +import { findVenueById } from '../venue/venue.repository'; +import { fetchActiveConflicts } from '../booking/booking.repository'; +import { generateAvailability, getBookableDates } from './availability.workflow'; +import { ResponseUtil } from '../../utils/responseUtils'; +import { logError, logInfo } from '../../utils/logger'; +import crypto from 'crypto'; +import { validateDateForVenue, type VenueIdParamDTO } from '../booking/booking.validator'; +import type { AvailabilityQueryDTO } from './availability.validator'; + +export const getVenueAvailability = async (req: Request, res: Response): Promise => { + try { + const validated = req.validated; + if (!validated?.params) { + ResponseUtil.badRequest(res, 'Validation failed'); + return; + } + + const { id } = validated.params as VenueIdParamDTO; + const { date } = validated.query as AvailabilityQueryDTO; + + const venue = await findVenueById(id); + if (!venue) { + ResponseUtil.notFound(res, 'Venue not found'); + return; + } + + // Only Approved, active, non-deleted venues expose availability. + // Return 404 (not 403) to avoid leaking venue existence for non-public venues. + if (venue.status !== 'Approved' || !venue.active || venue.deleted) { + ResponseUtil.notFound(res, 'Venue not found'); + return; + } + + if (!date) { + const bookableData = getBookableDates(venue); + ResponseUtil.success(res, 'Bookable dates calculated successfully', bookableData); + return; + } + + const dateCheck = validateDateForVenue(venue, date); + if (!dateCheck.valid) { + ResponseUtil.badRequest(res, dateCheck.reason ?? 'This date is not available for booking.'); + return; + } + + const sessionToken = req.headers['x-session-token'] as string | undefined; + const sessionTokenHash = sessionToken + ? crypto.createHash('sha256').update(sessionToken).digest('hex') + : undefined; + const userId = req.user?.userId; + + logInfo('Availability check: self-lock exclusion parameters extracted', { + venueId: id, + date, + userId: userId ?? 'Unauthenticated', + hasSessionToken: !!sessionToken, + sessionTokenHash, + }); + + // Fetch active conflicts (Bookings and locks) + const conflicts = await fetchActiveConflicts(id, date, { + excludeUserId: userId, + excludeSessionTokenHash: sessionTokenHash, + }); + + // Calculate availability data + const availabilityData = generateAvailability(venue, date, conflicts); + ResponseUtil.success(res, 'Availability calculated successfully', availabilityData); + return; + } catch (error) { + logError('Error computing availability', error as Record); + ResponseUtil.serverUnavailable(res, 'Failed to compute availability'); + return; + } +}; diff --git a/server/src/modules/availability/availability.repository.ts b/server/src/modules/availability/availability.repository.ts new file mode 100644 index 0000000000..8ebf9b34ca --- /dev/null +++ b/server/src/modules/availability/availability.repository.ts @@ -0,0 +1,16 @@ +import { fetchActiveConflicts } from '../booking/booking.repository'; + +/** + * Fetches all active time-slot conflicts (Locks + confirmed Bookings) + * for a given venue and date. + * + * Delegates to `booking.repository.fetchActiveConflicts` which is the + * single source of truth for this query, shared by both the availability + * check and the webhook collision re-check. + */ +export const fetchConflictsForDate = async ( + venueId: string, + date: string +): Promise<{ start: number; end: number }[]> => { + return fetchActiveConflicts(venueId, date); +}; diff --git a/server/src/modules/availability/availability.router.ts b/server/src/modules/availability/availability.router.ts new file mode 100644 index 0000000000..9a0dac7d47 --- /dev/null +++ b/server/src/modules/availability/availability.router.ts @@ -0,0 +1,161 @@ +import { Router } from 'express'; +import type { Router as ExpressRouter } from 'express'; +import { + validateBody, + validateParams, + validateQuery, +} from '../../middlewares/validation.middleware'; +import { verifyAccessToken, verifyAccessTokenOptional } from '../../middlewares/auth.middleware'; +import { idempotencyMiddleware } from '../../middlewares/idempotency.middleware'; +import { requirePermission } from '../../middlewares/rbac.middleware'; +import { PERMISSIONS as P } from '../../constants/permissions'; +import { venueIdParamSchema } from '../venue/venue.validator'; +import { blockSlotBodySchema } from '../booking/booking.validator'; +import * as bookingController from '../booking/booking.controller'; +import * as availabilityController from './availability.controller'; +import { availabilityQuerySchema } from './availability.validator'; + +const router: ExpressRouter = Router(); + +/** + * @openapi + * /availability/{id}: + * get: + * tags: [Availability] + * summary: Get venue availability slots for a given date range + * parameters: + * - in: path + * name: id + * required: true + * description: Venue MongoDB ObjectId + * schema: + * type: string + * example: 64b1f2c3d4e5f6a7b8c9d0e1 + * - in: query + * name: date + * required: false + * description: Target date in YYYY-MM-DD format. If omitted, returns bookable dates for the venue. + * schema: + * type: string + * example: '2025-12-25' + * responses: + * 200: + * description: Availability grid or bookable dates metadata + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 400: + * description: Invalid venue ID or date format + * 404: + * description: Venue not found + */ +router + .route('/:id') + .get( + verifyAccessTokenOptional, + validateParams(venueIdParamSchema), + validateQuery(availabilityQuerySchema), + availabilityController.getVenueAvailability + ); + +/** + * @openapi + * /availability/{id}/block: + * post: + * tags: [Availability] + * summary: Block a time slot for a venue (Step 1 of 3 in the booking flow) + * description: | + * Acquires a time-limited DB lock on the requested slot. + * Does NOT create a Razorpay order — that happens in Step 2 (`/bookings/checkout`). + * Lock expires automatically if checkout is not initiated within the window. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * description: Venue MongoDB ObjectId + * schema: + * type: string + * example: 64b1f2c3d4e5f6a7b8c9d0e1 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [date, startTime, endTime, expectedPrice] + * properties: + * date: + * type: string + * description: Booking date in YYYY-MM-DD + * example: '2025-12-25' + * startTime: + * type: integer + * description: Slot start in minutes from midnight (0–1439) + * example: 600 + * endTime: + * type: integer + * description: Slot end in minutes from midnight (1–1440) + * example: 720 + * expectedPrice: + * type: number + * description: Client-calculated price in rupees for server sanity check + * example: 5000 + * responses: + * 200: + * description: Slot locked — returns lock ID for use in checkout + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * data: + * type: object + * properties: + * lockId: + * type: string + * expiresAt: + * type: string + * format: date-time + * 400: + * description: Slot unavailable or validation error + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * 409: + * description: Slot already locked by another user + */ +router + .route('/:id/block') + .post( + verifyAccessToken, + idempotencyMiddleware(), + requirePermission(P.bookings.create), + validateParams(venueIdParamSchema), + validateBody(blockSlotBodySchema), + bookingController.blockSlot + ); + +/** + * @openapi + * /availability/lock: + * delete: + * tags: [Availability] + * summary: Release a slot lock + * description: Manually release a lock before it expires + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Lock released successfully + * 401: + * description: Not authenticated + */ +router.route('/lock').delete(verifyAccessTokenOptional, bookingController.releaseLock); + +export { router as availabilityRouter }; diff --git a/server/src/modules/availability/availability.validator.ts b/server/src/modules/availability/availability.validator.ts new file mode 100644 index 0000000000..e975f07287 --- /dev/null +++ b/server/src/modules/availability/availability.validator.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { isBookableDate } from '../../utils/timeUtils'; + +export const availabilityQuerySchema = z.object({ + date: z + .string() + .trim() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date query parameter must be in YYYY-MM-DD format') + .optional() + .refine((val) => !val || isBookableDate(val), { + message: 'Date must be between tomorrow and 90 days from today', + }), +}); + +export type AvailabilityQueryDTO = z.infer; diff --git a/server/src/modules/availability/availability.workflow.ts b/server/src/modules/availability/availability.workflow.ts new file mode 100644 index 0000000000..81336ed0f1 --- /dev/null +++ b/server/src/modules/availability/availability.workflow.ts @@ -0,0 +1,170 @@ +import type { IVenue } from '../venue/venue.types'; +import { + timeStringToMinutes, + minutesToTimeString, + checkOverlap, + toLocalDateString, +} from '../../utils/timeUtils'; + +interface AvailabilitySlot { + slotId: string; + name: string | null; + startTime: string; + endTime: string; + price: number; + isAvailable: boolean; + reason: string | null; +} + +interface AvailabilityResponse { + venueId: unknown; + date: string; + bookingType: 'fixedBooking' | 'flexibleBooking'; + slots: AvailabilitySlot[]; +} + +export const generateAvailability = ( + venue: IVenue, + date: string, + externalConflicts: { start: number; end: number }[] +): AvailabilityResponse => { + // Merge venue blocked times/breaks with external conflicts + const internalConflicts = (venue.blockedTimes ?? []).map((bt) => ({ + start: timeStringToMinutes(bt.fromTime), + end: timeStringToMinutes(bt.toTime), + })); + const allConflicts = [...externalConflicts, ...internalConflicts]; + + const slots: AvailabilitySlot[] = []; + + // Fixed bookings branch + if (venue.bookingType === 'fixedBooking') { + for (const pkg of venue.fixedPackages ?? []) { + const startMin = timeStringToMinutes(pkg.startTime); + const endMin = timeStringToMinutes(pkg.endTime); + + const isBlocked = checkOverlap(startMin, endMin, allConflicts); + + slots.push({ + slotId: pkg.slotName.replace(/\s+/g, '_').toLowerCase(), + name: pkg.slotName, + startTime: pkg.startTime, + endTime: pkg.endTime, + price: pkg.price, + isAvailable: !isBlocked, + reason: isBlocked ? 'UNAVAILABLE' : null, + }); + } + return { venueId: venue._id, date, bookingType: 'fixedBooking', slots }; + } + + // Flexible bookings branch + if (!venue.workingHours) { + throw new Error('Working hours are required for flexible booking venues'); + } + const openMin = timeStringToMinutes(venue.workingHours.open); + const closeMin = timeStringToMinutes(venue.workingHours.close); + const duration = venue.flexibleBooking?.slotDuration ?? 60; + const buffer = venue.flexibleBooking?.bufferTime ?? 0; + + let currentStart = openMin; + + while (currentStart + duration <= closeMin) { + const currentEnd = currentStart + duration; + const isBlocked = checkOverlap(currentStart, currentEnd, allConflicts); + + // Determine pricing based on base price or rules + if (!venue.pricing) { + throw new Error('Pricing configuration missing for flexible booking venue'); + } + let appliedPrice = venue.pricing.basePrice; + if (venue.pricing.pricingType === 'timeBasedPricing') { + for (const rule of venue.pricing.pricingRules) { + const ruleStart = timeStringToMinutes(rule.fromTime); + const ruleEnd = timeStringToMinutes(rule.toTime); + if (currentStart >= ruleStart && currentStart < ruleEnd) { + appliedPrice = rule.price; + break; + } + } + } + + slots.push({ + slotId: `${currentStart.toString()}-${currentEnd.toString()}`, + name: null, + startTime: minutesToTimeString(currentStart), + endTime: minutesToTimeString(currentEnd), + price: appliedPrice, + isAvailable: !isBlocked, + reason: isBlocked ? 'UNAVAILABLE' : null, + }); + + // Advance loop adding slot duration and buffer + currentStart = currentEnd + buffer; + } + + return { venueId: venue._id, date, bookingType: 'flexibleBooking', slots }; +}; + +export interface BookableDatesResponse { + bookableDates: string[]; + disabledDates: string[]; + maxDate: string; +} + +export const getBookableDates = (venue: IVenue): BookableDatesResponse => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const maxDateObj = new Date(today); + maxDateObj.setDate(maxDateObj.getDate() + 90); + + // Apply blockedAfterDate (tightest date wins) + if (venue.temporaryBlockAfterDate) { + const blockDate = new Date(venue.temporaryBlockAfterDate); + if (blockDate < maxDateObj) { + maxDateObj.setTime(blockDate.getTime()); + } + } + if (venue.inactivity?.blockedAfterDate) { + const blockDate = new Date(venue.inactivity.blockedAfterDate); + if (blockDate < maxDateObj) { + maxDateObj.setTime(blockDate.getTime()); + } + } + + const bookableDates: string[] = []; + const disabledDates: string[] = []; + + const blockedDatesStr = venue.blockedDates.map((d) => d.toISOString().split('T')[0]); + + const workingDays = venue.workingDays; + + for (let d = new Date(today); d <= maxDateObj; d.setDate(d.getDate() + 1)) { + const dateStr = toLocalDateString(d); + + if (d < tomorrow) { + disabledDates.push(dateStr); + continue; + } + + const dayName = d.toLocaleDateString('en-US', { weekday: 'long' }); + const isWorkingDay = workingDays.includes(dayName); + const isBlocked = blockedDatesStr.includes(dateStr); + + if (isWorkingDay && !isBlocked) { + bookableDates.push(dateStr); + } else { + disabledDates.push(dateStr); + } + } + + return { + bookableDates, + disabledDates, + maxDate: toLocalDateString(maxDateObj), + }; +}; diff --git a/server/src/modules/booking/booking.controller.ts b/server/src/modules/booking/booking.controller.ts new file mode 100644 index 0000000000..29d00239b5 --- /dev/null +++ b/server/src/modules/booking/booking.controller.ts @@ -0,0 +1,328 @@ +import type { Request, Response } from 'express'; +import { Types } from 'mongoose'; +import { ResponseUtil } from '../../utils/responseUtils'; +import { logError } from '../../utils/logger'; +import * as workflow from './booking.workflow'; +import * as service from './booking.service'; +import type { BookingStatusType } from '../../constants/booking.constants'; +import { verifyPaymentSignature } from '../../services/razorpay.service'; +import { getUserReviewedBookings } from '../review/review.service'; +import { findVenueById } from '../venue/venue.repository'; +import type { + BlockSlotBodyDTO, + CheckoutBodyDTO, + SaveBookerDetailsBodyDTO, + VerifyPaymentBodyDTO, + VenueIdParamDTO, + AdminBookingFiltersDTO, +} from './booking.validator'; + +export const blockSlot = async (req: Request, res: Response): Promise => { + try { + const validated = req.validated; + if (!validated) { + ResponseUtil.badRequest(res, 'Validation failed'); + return; + } + const { id: venueId } = validated.params as VenueIdParamDTO; + const { date, selectedSlots, expectedPrice } = validated.body as BlockSlotBodyDTO; + const userId = req.user?.userId; + + if (!userId) { + ResponseUtil.unauthorized(res, 'User identity not found in token'); + return; + } + + // Guard: only Approved, active, non-deleted venues accept slot locks. + // Return 404 (not 403) to avoid leaking existence of non-public venues. + const venue = await findVenueById(venueId); + if (!venue || !venue.active || venue.status !== 'Approved' || venue.deleted) { + ResponseUtil.notFound(res, 'Venue not found'); + return; + } + + const sessionToken = req.headers['x-session-token'] as string | undefined; + + const result = await workflow.blockSlotWorkflow( + venueId, + userId, + date, + expectedPrice, + selectedSlots, + sessionToken + ); + + ResponseUtil.success(res, 'Slot locked successfully. Proceed to checkout.', result); + } catch (err) { + const error = err as Error; + if (error.message.includes('not found') || error.message.includes('unavailable')) { + ResponseUtil.notFound(res, error.message); + } else if ( + error.message.includes('no longer available') || + error.message.includes('Someone else is booking') + ) { + ResponseUtil.conflict(res, error.message); + } else if (error.message.includes('Invalid') || error.message.includes('mismatch')) { + ResponseUtil.badRequest(res, error.message); + } else { + logError('blockSlot controller error', { + module: 'booking.controller.ts/blockSlot', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Failed to acquire slot lock. Please try again.'); + } + } +}; + +export const saveBookerDetails = async (req: Request, res: Response): Promise => { + try { + const validated = req.validated; + if (!validated) { + ResponseUtil.badRequest(res, 'Validation failed'); + return; + } + const { lockId, guestCount, eventType, bookerInfo } = + validated.body as SaveBookerDetailsBodyDTO; + const userId = req.user?.userId; + + if (!userId) { + ResponseUtil.unauthorized(res, 'User identity not found in token'); + return; + } + + await workflow.saveBookerDetailsWorkflow(lockId, userId, guestCount, eventType, bookerInfo); + + ResponseUtil.success(res, 'Booker details saved successfully.'); + } catch (err) { + const error = err as Error; + if (error.message.includes('expired')) { + ResponseUtil.badRequest(res, error.message); + } else { + logError('saveBookerDetails controller error', { + module: 'booking.controller.ts/saveBookerDetails', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Failed to save booker details. Please try again.'); + } + } +}; + +export const initCheckout = async (req: Request, res: Response): Promise => { + try { + const validated = req.validated; + if (!validated) { + ResponseUtil.badRequest(res, 'Validation failed'); + return; + } + const { lockId } = validated.body as CheckoutBodyDTO; + const userId = req.user?.userId; + + if (!userId) { + ResponseUtil.unauthorized(res, 'User identity not found in token'); + return; + } + + const result = await workflow.initCheckoutWorkflow(userId, lockId); + + ResponseUtil.success(res, 'Checkout order created successfully.', result); + } catch (err) { + const error = err as Error; + if (error.message.includes('expired') || error.message.includes('Insufficient')) { + ResponseUtil.badRequest(res, error.message); + } else { + logError('initCheckout controller error', { + module: 'booking.controller.ts/initCheckout', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Failed to create checkout order. Please try again.'); + } + } +}; + +export const verifyPayment = async (req: Request, res: Response): Promise => { + try { + const { orderId, paymentId, signature } = req.validated?.body as VerifyPaymentBodyDTO; + + const isAuthentic = verifyPaymentSignature({ + orderId, + paymentId, + signature, + }); + + if (!isAuthentic) { + ResponseUtil.badRequest(res, 'Invalid payment signature'); + return; + } + + const booking = await service.getBookingByPaymentReference(paymentId); + if (booking) { + const bookingRef = `BMV-${booking._id.toString().slice(-6).toUpperCase()}`; + ResponseUtil.success(res, 'Payment verified successfully', { + _id: booking._id.toString(), + bookingRef, + }); + } else { + ResponseUtil.success(res, 'Payment verified successfully'); + } + } catch (err) { + const error = err as Error; + logError('verifyPayment controller error', { + module: 'booking.controller.ts/verifyPayment', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Failed to verify payment'); + } +}; + +export const releaseLock = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + const sessionToken = req.headers['x-session-token'] as string | undefined; + + await workflow.releaseLockWorkflow(userId, sessionToken); + + ResponseUtil.success(res, 'Lock released successfully'); + } catch (err) { + const error = err as Error; + logError('releaseLock controller error', { + module: 'booking.controller.ts/releaseLock', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Failed to release lock'); + } +}; + +export const getMyBookings = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'User identity not found in token'); + return; + } + + const [bookingsResult, reviewedBookingIds] = await Promise.all([ + service.getMyBookings(userId), + getUserReviewedBookings(userId), + ]); + + // Merge hasReview flag into completed bookings + const result = { + ...bookingsResult, + bookings: { + ...bookingsResult.bookings, + completed: bookingsResult.bookings.completed.map((booking: Record) => ({ + ...booking, + hasReview: reviewedBookingIds.has(booking._id as string), + })), + }, + }; + + ResponseUtil.success(res, 'My bookings retrieved successfully', result); + } catch (err) { + const error = err as Error; + logError('fetchMyBookings controller error', { + module: 'booking.controller.ts/fetchMyBookings', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Failed to fetch your bookings.'); + } +}; + +export const getBookingById = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'User identity not found in token'); + return; + } + + const bookingId = req.params.bookingRefId as string; + const isBookingRef = bookingId.toUpperCase().startsWith('BMV-') && bookingId.length === 10; + if (!Types.ObjectId.isValid(bookingId) && !isBookingRef) { + ResponseUtil.badRequest(res, 'Invalid booking ID or reference'); + return; + } + + const booking = await service.getBookingById(bookingId, userId); + if (!booking) { + ResponseUtil.notFound(res, 'Booking not found'); + return; + } + + ResponseUtil.success(res, 'Booking details retrieved successfully', booking); + } catch (err) { + const error = err as Error; + logError('getBookingById controller error', { + module: 'booking.controller.ts/getBookingById', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Failed to fetch booking details.'); + } +}; + +export const cancelBooking = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'User identity not found in token'); + return; + } + + const bookingId = req.params.bookingRefId as string; + const reason = (req.body as { reason?: string }).reason; + const isBookingRef = bookingId.toUpperCase().startsWith('BMV-') && bookingId.length === 10; + if (!Types.ObjectId.isValid(bookingId) && !isBookingRef) { + ResponseUtil.badRequest(res, 'Invalid booking ID or reference'); + return; + } + + await workflow.cancelBookingWorkflow(userId, bookingId, reason); + + ResponseUtil.success(res, 'Booking cancelled successfully'); + } catch (err) { + const error = err as Error; + if (error.message.includes('not found')) { + ResponseUtil.notFound(res, error.message); + } else if (error.message.includes('already been cancelled')) { + ResponseUtil.conflict(res, error.message); + } else if ( + error.message.includes('Cannot') || + error.message.includes('Only confirmed') || + error.message.includes('Cancellation window') + ) { + ResponseUtil.badRequest(res, error.message); + } else { + logError('cancelBooking controller error', { + module: 'booking.controller.ts/cancelBooking', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Failed to cancel booking. Please try again.'); + } + } +}; + +export const getAllBookings = async (req: Request, res: Response): Promise => { + try { + const { status, venueId } = req.validated?.query as AdminBookingFiltersDTO; + const paginationParams = req.pagination ?? { page: 1, limit: 10, skip: 0, sort: '' }; + const result = await service.getAllBookings(paginationParams, { + status: status as BookingStatusType | undefined, + venueId, + }); + + ResponseUtil.paginated( + res, + 'All bookings retrieved successfully', + result.bookings, + result.pagination, + 'bookings' + ); + } catch (err) { + const error = err as Error; + logError('getAllBookings controller error', { + module: 'booking.controller.ts/getAllBookings', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Failed to fetch all bookings.'); + } +}; diff --git a/server/src/modules/booking/booking.repository.ts b/server/src/modules/booking/booking.repository.ts new file mode 100644 index 0000000000..a0cc195a16 --- /dev/null +++ b/server/src/modules/booking/booking.repository.ts @@ -0,0 +1,617 @@ +import { Types, type ClientSession } from 'mongoose'; +import { buildPaginationMeta } from '../../utils/paginationUtils'; +import { BookingStatus, type BookingStatusType } from '../../constants/booking.constants'; +import { PaymentStatus, type PaymentStatusType } from '../../constants/payment.constants'; +import { LockModel } from './models/lock.model'; +import { BookingModel } from './models/booking.model'; +import { FailedBookingModel } from './models/failedBooking.model'; +import { ProcessedWebhookModel } from './models/processedWebhook.model'; +import { VenueModel } from '../venue/venue.model'; +import type { IVenue, IRefundRule } from '../venue/venue.types'; +import type { IBooking } from './booking.types'; +import type { ILock, IContractSnapshot } from './lock.types'; +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import { minutesToTimeString } from '../../utils/timeUtils'; + +function resolveRefundPct(venueRaw: unknown, bookingDate: string): { pct: number; label: string } { + const venue = venueRaw as IVenue; + + if (venue.cancellation.policy !== 'refundable') { + return { pct: 0, label: 'Non-refundable' }; + } + + const rules: IRefundRule[] = venue.cancellation.refundRules; + if (rules.length === 0) { + if (venue.cancellation.refundType === 'fullRefund') { + return { pct: 100, label: 'Full Refund' }; + } + return { pct: 0, label: 'Non-refundable' }; + } + + const sortedRules = [...rules].sort((a, b) => b.daysBefore - a.daysBefore); + + const nowMs = Date.now(); + const bookingDateMs = new Date(`${bookingDate}T00:00:00`).getTime(); + const daysUntilBooking = Math.floor((bookingDateMs - nowMs) / (1000 * 60 * 60 * 24)); + + const matchedRule = sortedRules.find((rule) => daysUntilBooking >= rule.daysBefore); + + if (matchedRule) { + return { + pct: matchedRule.refundPercentage, + label: `Refundable (${String(matchedRule.refundPercentage)}% up to ${String(matchedRule.daysBefore)} days before)`, + }; + } + + return { pct: 0, label: 'Non-refundable' }; +} + +export function resolveRefundPctFromSnapshot( + cancellation: IContractSnapshot['cancellation'], + bookingDate: string +): { pct: number; label: string } { + if (cancellation.policy !== 'refundable') { + return { pct: 0, label: 'Non-refundable' }; + } + + const rules = cancellation.refundRules; + if (rules.length === 0) { + if (cancellation.refundType === 'fullRefund') { + return { pct: 100, label: 'Full Refund' }; + } + return { pct: 0, label: 'Non-refundable' }; + } + + const sortedRules = [...rules].sort((a, b) => b.daysBefore - a.daysBefore); + const nowMs = Date.now(); + const bookingDateMs = new Date(`${bookingDate}T00:00:00`).getTime(); + const daysUntilBooking = Math.floor((bookingDateMs - nowMs) / (1000 * 60 * 60 * 24)); + + const matchedRule = sortedRules.find((rule) => daysUntilBooking >= rule.daysBefore); + if (matchedRule) { + return { + pct: matchedRule.refundPercentage, + label: `Refundable (${String(matchedRule.refundPercentage)}% up to ${String(matchedRule.daysBefore)} days before)`, + }; + } + + return { pct: 0, label: 'Non-refundable' }; +} + +export interface AggregatedBooking { + _id: Types.ObjectId; + userId: Types.ObjectId; + venueId: Types.ObjectId; + date: string; + startTime: number; + endTime: number; + price: number; + paymentReference: string; + status: BookingStatusType; + paymentStatus?: PaymentStatusType; + guestCount?: number; + eventType?: string; + bookerInfo?: { + name?: string; + email?: string; + phone?: string; + place?: string; + note?: string; + }; + paymentMethod?: string; + advancePaid?: number; + remainingAmount?: number; + createdAt: Date; + updatedAt: Date; + venue: IVenue & { _id: Types.ObjectId }; + contractSnapshot?: IContractSnapshot; +} + +export async function fetchMyBookings(userId: string): Promise<{ + bookings: { + upcoming: Record[]; + cancelled: Record[]; + completed: Record[]; + }; +}> { + const bookings = await BookingModel.aggregate([ + { $match: { userId: new Types.ObjectId(userId) } }, + { + $lookup: { + from: 'Venues', + localField: 'venueId', + foreignField: '_id', + as: 'venue', + }, + }, + { $unwind: '$venue' }, + { $sort: { date: 1, startTime: 1 } }, + ]); + + const now = new Date(); + const upcoming = []; + const cancelled = []; + const completed = []; + + for (const b of bookings) { + const bookingStart = new Date(`${b.date}T00:00:00`); + bookingStart.setMinutes(b.startTime); + const bookingEnd = new Date(`${b.date}T00:00:00`); + bookingEnd.setMinutes(b.endTime); + + let uiStatus: string; + if (b.status === BookingStatus.CANCELLED) { + uiStatus = 'cancelled'; + } else if (b.status === BookingStatus.COMPLETED) { + uiStatus = 'completed'; + } else if (b.status === BookingStatus.IN_PROGRESS) { + uiStatus = 'in_progress'; + } else if (bookingEnd < now) { + uiStatus = 'completed'; + } else if (bookingStart <= now && bookingEnd > now) { + uiStatus = 'in_progress'; + } else { + uiStatus = 'upcoming'; + } + + const dto = { + _id: b._id.toString(), + bookingRef: `BMV-${b._id.toString().slice(-6).toUpperCase()}`, + venueName: b.venue.name, + venueId: b.venue._id.toString(), + city: b.venue.city, + district: b.venue.district, + coverImage: b.venue.coverImage, + date: new Date(b.date).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + timeRange: `${minutesToTimeString(b.startTime)} - ${minutesToTimeString(b.endTime)}`, + bookedOn: new Date(b.createdAt).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + guestCount: b.guestCount, + eventType: b.eventType, + totalPrice: b.price, + paymentMethod: b.paymentMethod, + status: b.status, + paymentStatus: b.paymentStatus, + uiStatus, + }; + + if (uiStatus === 'cancelled') cancelled.push(dto); + else if (uiStatus === 'completed') completed.push(dto); + else upcoming.push(dto); + } + + return { + bookings: { + upcoming, + cancelled, + completed, + }, + }; +} + +export async function fetchBookingById( + bookingId: string, + userId: string +): Promise | null> { + let matchStage: Record; + if (bookingId.toUpperCase().startsWith('BMV-')) { + const suffix = bookingId.toUpperCase().replace('BMV-', '').toLowerCase(); + matchStage = { + userId: new Types.ObjectId(userId), + $expr: { + $eq: [{ $substr: [{ $toString: '$_id' }, 18, 6] }, suffix], + }, + }; + } else { + matchStage = { _id: new Types.ObjectId(bookingId), userId: new Types.ObjectId(userId) }; + } + + const [booking] = await BookingModel.aggregate([ + { $match: matchStage }, + { + $lookup: { + from: 'Venues', + localField: 'venueId', + foreignField: '_id', + as: 'venue', + }, + }, + { $unwind: '$venue' }, + ]); + + if (!booking) return null; + + const now = new Date(); + const bookingStart = new Date(`${booking.date}T00:00:00`); + bookingStart.setMinutes(booking.startTime); + const bookingEnd = new Date(`${booking.date}T00:00:00`); + bookingEnd.setMinutes(booking.endTime); + + let uiStatus: string; + if (booking.status === BookingStatus.CANCELLED) { + uiStatus = 'cancelled'; + } else if (booking.status === BookingStatus.COMPLETED) { + uiStatus = 'completed'; + } else if (booking.status === BookingStatus.IN_PROGRESS) { + uiStatus = 'in_progress'; + } else if (bookingEnd < now) { + uiStatus = 'completed'; + } else if (bookingStart <= now && bookingEnd > now) { + uiStatus = 'in_progress'; + } else { + uiStatus = 'upcoming'; + } + + return { + _id: booking._id.toString(), + bookingRef: `BMV-${booking._id.toString().slice(-6).toUpperCase()}`, + venueName: booking.venue.name, + venueId: booking.venue._id.toString(), + city: booking.venue.city, + district: booking.venue.district, + coverImage: booking.venue.coverImage, + date: new Date(booking.date).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + timeRange: `${minutesToTimeString(booking.startTime)} - ${minutesToTimeString(booking.endTime)}`, + bookedOn: new Date(booking.createdAt).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + guestCount: booking.guestCount, + eventType: booking.eventType, + totalPrice: booking.price, + paymentMethod: booking.paymentMethod, + status: booking.status, + paymentStatus: booking.paymentStatus, + uiStatus, + address: booking.venue.address, + contactPhone: booking.venue.contact.phone, + contactEmail: booking.venue.contact.email, + googleMapsUrl: booking.venue.googleMapsUrl, + amenities: booking.venue.amenities, + cancellationPolicy: booking.contractSnapshot + ? resolveRefundPctFromSnapshot(booking.contractSnapshot.cancellation, booking.date).label + : resolveRefundPct(booking.venue, booking.date).label, + cancellationRefundPct: booking.contractSnapshot + ? resolveRefundPctFromSnapshot(booking.contractSnapshot.cancellation, booking.date).pct + : resolveRefundPct(booking.venue, booking.date).pct, + paymentReference: booking.paymentReference, + bookerInfo: booking.bookerInfo, + }; +} + +// Conflict resolution +export async function fetchActiveConflicts( + venueId: string, + date: string, + options?: { + excludeUserId?: string; + excludeSessionTokenHash?: string; + session?: ClientSession; + } +): Promise<{ start: number; end: number }[]> { + const vId = new Types.ObjectId(venueId); + + let lockQuery: Record = { venueId: vId, date }; + const excludeConditions: Record[] = []; + + if (options?.excludeUserId) { + excludeConditions.push({ userId: new Types.ObjectId(options.excludeUserId) }); + } + if (options?.excludeSessionTokenHash) { + excludeConditions.push({ sessionTokenHash: options.excludeSessionTokenHash }); + } + + if (excludeConditions.length > 0) { + lockQuery = { ...lockQuery, $nor: excludeConditions }; + } + + const locks = await LockModel.find(lockQuery) + .session(options?.session ?? null) + .lean(); + const bookings = await BookingModel.find({ + venueId: vId, + date, + status: { $in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED, BookingStatus.IN_PROGRESS] }, + }) + .session(options?.session ?? null) + .lean(); + + return [ + ...locks.map((l: ILock) => ({ start: l.startTime, end: l.endTime })), + ...bookings.map((b: IBooking) => ({ start: b.startTime, end: b.endTime })), + ]; +} + +export async function fetchApprovedVenueForBooking(venueId: string): Promise { + return VenueModel.findOne({ + _id: new Types.ObjectId(venueId), + status: 'Approved', + active: true, + deleted: false, + }).lean(); +} + +// Booking writes +export interface CreateBookingData { + venueId: string; + userId: string; + date: string; + startTime: number; + endTime: number; + price: number; + paymentReference: string; + status?: BookingStatusType; + paymentStatus?: PaymentStatusType; + guestCount?: number; + eventType?: string; + bookerInfo?: { + name?: string; + email?: string; + phone?: string; + place?: string; + note?: string; + }; + paymentMethod?: string; + advancePaid?: number; + remainingAmount?: number; + contractSnapshot?: IContractSnapshot; +} + +export async function createBooking(data: CreateBookingData): Promise { + const booking = await BookingModel.create({ + venueId: new Types.ObjectId(data.venueId), + userId: new Types.ObjectId(data.userId), + date: data.date, + startTime: data.startTime, + endTime: data.endTime, + price: data.price, + paymentReference: data.paymentReference, + status: data.status ?? BookingStatus.CONFIRMED, + paymentStatus: data.paymentStatus ?? PaymentStatus.PAID, + guestCount: data.guestCount, + eventType: data.eventType, + bookerInfo: data.bookerInfo, + paymentMethod: data.paymentMethod, + advancePaid: data.advancePaid, + remainingAmount: data.remainingAmount, + contractSnapshot: data.contractSnapshot, + }); + return booking; +} + +// FailedBooking audit +export interface CreateFailedBookingData { + venueId: string; + userId: string; + date: string; + startTime: number; + endTime: number; + amountPaid: number; + paymentReference: string; + refundReference: string; + reason: string; +} + +// Inserts an immutable audit record for a collision-triggered refund. +export async function createFailedBooking(data: CreateFailedBookingData): Promise { + await FailedBookingModel.create({ + venueId: new Types.ObjectId(data.venueId), + userId: new Types.ObjectId(data.userId), + date: data.date, + startTime: data.startTime, + endTime: data.endTime, + amountPaid: data.amountPaid, + paymentReference: data.paymentReference, + refundReference: data.refundReference, + reason: data.reason, + }); +} + +// Idempotency gate +/** + * Inserts the Razorpay event_id into ProcessedWebhookModel. + * Throws a MongoServerError with code 11000 if the event was already processed. + * The caller MUST catch error.code === 11000 and return 200 immediately. + */ +export async function markWebhookProcessed(eventId: string): Promise { + await ProcessedWebhookModel.create({ eventId }); +} + +export async function findAllBookings( + paginationParams: PaginationParams, + filters?: { status?: BookingStatusType; venueId?: string } +): Promise> { + const { limit, skip } = paginationParams; + const matchStage: Record = {}; + + if (filters?.status) matchStage.status = filters.status; + if (filters?.venueId) matchStage.venueId = new Types.ObjectId(filters.venueId); + + const [bookings, totalCount] = await Promise.all([ + BookingModel.aggregate([ + { $match: matchStage }, + { $sort: { createdAt: -1 } }, + { $skip: skip }, + { $limit: limit }, + { + $lookup: { + from: 'Venues', + localField: 'venueId', + foreignField: '_id', + as: 'venue', + }, + }, + { $unwind: '$venue' }, + { + $lookup: { + from: 'Users', + localField: 'userId', + foreignField: '_id', + as: 'user', + }, + }, + { $unwind: { path: '$user', preserveNullAndEmptyArrays: true } }, + { + $addFields: { + bookerEmail: '$bookerInfo.email', + bookerPhone: '$bookerInfo.phone', + bookerName: '$bookerInfo.name', + }, + }, + ]), + BookingModel.countDocuments(matchStage).exec(), + ]); + + // Compute UI status for each booking + const now = new Date(); + const bookingsWithUIStatus = bookings.map((booking) => { + if (booking.status === 'cancelled') { + return { ...booking, uiStatus: 'cancelled' as const }; + } + if (booking.status === 'completed') { + return { ...booking, uiStatus: 'completed' as const }; + } + if (booking.status === 'in_progress') { + return { ...booking, uiStatus: 'in_progress' as const }; + } + const eventStart = new Date(`${booking.date}T00:00:00`); + eventStart.setMinutes(booking.startTime); + const eventEnd = new Date(`${booking.date}T00:00:00`); + eventEnd.setMinutes(booking.endTime); + if (eventEnd < now) { + return { ...booking, uiStatus: 'completed' as const, status: 'completed' as const }; + } + if (eventStart <= now && eventEnd > now) { + return { ...booking, uiStatus: 'in_progress' as const, status: 'in_progress' as const }; + } + return { ...booking, uiStatus: 'confirmed' as const }; + }); + + return { + bookings: bookingsWithUIStatus, + pagination: buildPaginationMeta(totalCount, paginationParams), + }; +} + +export async function updateLockBookerDetails( + lockId: string, + userId: string, + guestCount?: number, + eventType?: string, + bookerInfo?: Record +): Promise { + return LockModel.findOneAndUpdate( + { _id: new Types.ObjectId(lockId), userId: new Types.ObjectId(userId) }, + { + $set: { + guestCount, + eventType, + bookerInfo, + }, + }, + { new: true } + ).lean(); +} +export async function fetchUserBookingByRefIdOrId( + bookingId: string, + userId: string +): Promise { + const isBookingRef = bookingId.toUpperCase().startsWith('BMV-') && bookingId.length === 10; + if (isBookingRef) { + const suffix = bookingId.toUpperCase().replace('BMV-', ''); + const userBookings = await BookingModel.find({ userId: new Types.ObjectId(userId) }); + return userBookings.find((b) => b._id.toString().slice(-6).toUpperCase() === suffix) ?? null; + } else { + return BookingModel.findOne({ + _id: new Types.ObjectId(bookingId), + userId: new Types.ObjectId(userId), + }); + } +} + +export async function findLockById(lockId: string, userId: string): Promise { + return LockModel.findOne({ + _id: new Types.ObjectId(lockId), + userId: new Types.ObjectId(userId), + }).lean(); +} + +export async function findLockByIdRaw(lockId: string): Promise { + return LockModel.findById(lockId).lean(); +} + +export async function deleteLockById(lockId: string): Promise { + await LockModel.deleteOne({ _id: new Types.ObjectId(lockId) }); +} + +export async function createLock( + data: Partial[], + session?: ClientSession +): Promise { + return LockModel.create(data, { session }); +} + +export async function deleteLocksByConditions( + deleteConditions: Record[], + session?: ClientSession +): Promise { + if (deleteConditions.length > 0) { + return LockModel.deleteMany({ $or: deleteConditions }, { session }); + } + return null; +} + +export async function fetchBookingByPaymentReference(paymentId: string): Promise { + return BookingModel.findOne({ paymentReference: paymentId }).lean(); +} + +// Atomically transitions CONFIRMED -> CANCELLED. Returns the pre-update +// document if this call won the race, or null if the booking was already +// cancelled (by a concurrent request or otherwise). This is the sole gate +// that must pass before a refund is issued — it guarantees at most one +// caller can ever proceed to refund for a given booking. +export async function claimBookingForCancellation( + bookingId: string, + userId: string, + reason?: string +): Promise { + return BookingModel.findOneAndUpdate( + { + _id: new Types.ObjectId(bookingId), + userId: new Types.ObjectId(userId), + status: BookingStatus.CONFIRMED, + }, + { + $set: { + status: BookingStatus.CANCELLED, + paymentStatus: PaymentStatus.REFUNDED, + ...(reason ? { cancellationReason: reason } : {}), + }, + }, + { new: false } + ).lean(); +} + +export async function findVerifiedUserIds( + venueId: string, + userIds: string[] +): Promise> { + const verified = await BookingModel.find({ + venueId, + userId: { $in: userIds }, + status: { $ne: BookingStatus.CANCELLED }, + }).distinct('userId'); + return new Set(verified.map((id) => id.toString())); +} diff --git a/server/src/modules/booking/booking.router.ts b/server/src/modules/booking/booking.router.ts new file mode 100644 index 0000000000..d961d8778e --- /dev/null +++ b/server/src/modules/booking/booking.router.ts @@ -0,0 +1,307 @@ +import { Router } from 'express'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { idempotencyMiddleware } from '../../middlewares/idempotency.middleware'; +import { requireRole, requirePermission } from '../../middlewares/rbac.middleware'; +import { + validateBody, + validateParams, + validateQuery, +} from '../../middlewares/validation.middleware'; +import { PERMISSIONS as P } from '../../constants/permissions'; +import { + checkoutBodySchema, + verifyPaymentBodySchema, + fetchMyBookingsQuerySchema, + saveBookerDetailsBodySchema, + bookingRefIdParamSchema, + adminBookingFiltersSchema, +} from './booking.validator'; +import * as controller from './booking.controller'; +import { paginationMiddleware } from '../../middlewares/pagination.middleware'; + +const router: Router = Router(); + +/** + * @openapi + * /bookings/checkout: + * post: + * tags: [Bookings] + * summary: Initiate checkout — create a Razorpay order (Step 2 of 3) + * description: | + * Validates the slot lock is still alive and within the buffer window, + * then creates a Razorpay order. Requires a valid `lockId` from Step 1. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [lockId] + * properties: + * lockId: + * type: string + * description: MongoDB ObjectId returned from /availability/:id/block + * example: 64b1f2c3d4e5f6a7b8c9d0e2 + * responses: + * 200: + * description: Razorpay order created — return order ID to client for payment + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * data: + * type: object + * properties: + * orderId: + * type: string + * amount: + * type: integer + * description: Amount in paise + * currency: + * type: string + * example: INR + * 400: + * description: Invalid lockId or lock expired + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * 409: + * description: Lock no longer valid or slot taken + */ +router + .route('/checkout') + .post( + verifyAccessToken, + idempotencyMiddleware(), + requirePermission(P.bookings.create), + validateBody(checkoutBodySchema), + controller.initCheckout + ); + +/** + * @openapi + * /bookings/booker-details: + * patch: + * tags: [Bookings] + * summary: Save booker details before checkout + * description: Save booker info into the lock + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [lockId, guestCount, eventType, bookerInfo] + * properties: + * lockId: + * type: string + * description: MongoDB ObjectId from slot block + * guestCount: + * type: integer + * description: Number of guests attending + * eventType: + * type: string + * description: Type of event (e.g. wedding, corporate) + * bookerInfo: + * type: object + * required: [name, email, phone, place] + * properties: + * name: + * type: string + * email: + * type: string + * format: email + * phone: + * type: string + * place: + * type: string + * note: + * type: string + * responses: + * 200: + * description: Booker details saved successfully + * 400: + * description: Validation error + * 401: + * description: Not authenticated + */ +router + .route('/booker-details') + .patch( + verifyAccessToken, + validateBody(saveBookerDetailsBodySchema), + controller.saveBookerDetails + ); + +/** + * @openapi + * /bookings/verify-payment: + * post: + * tags: [Bookings] + * summary: Verify Razorpay payment signature (Step 3 of 3) + * description: | + * Client-side verification step called after the Razorpay payment modal closes. + * The server re-verifies the HMAC signature before confirming the booking. + * Final booking creation is triggered by the Razorpay webhook automatically. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [orderId, paymentId, signature] + * properties: + * orderId: + * type: string + * example: order_OIBFHxxx + * paymentId: + * type: string + * example: pay_OIBFHyyy + * signature: + * type: string + * example: abc123hmac + * responses: + * 200: + * description: Payment signature verified + * 400: + * description: Invalid or mismatched signature + * 401: + * description: Not authenticated + */ +router + .route('/verify-payment') + .post( + verifyAccessToken, + requirePermission(P.bookings.create), + validateBody(verifyPaymentBodySchema), + controller.verifyPayment + ); + +/** + * @openapi + * /bookings/my-bookings: + * get: + * tags: [Bookings] + * summary: Get all bookings for the authenticated user (grouped by status) + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: List of user's bookings + * 401: + * description: Not authenticated + */ +router + .route('/my-bookings') + .get(verifyAccessToken, validateQuery(fetchMyBookingsQuerySchema), controller.getMyBookings); + +/** + * @openapi + * /bookings/all: + * get: + * tags: [Bookings] + * summary: Get all bookings across all venues (Admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * - in: query + * name: status + * schema: + * type: string + * enum: [confirmed, pending, cancelled, completed] + * - in: query + * name: venueId + * schema: + * type: string + * description: Filter by venue (MongoDB ObjectId) + * - in: query + * name: sort + * schema: + * type: string + * description: Sort order field + * responses: + * 200: + * description: Paginated list of all bookings + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + */ +router + .route('/all') + .get( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.bookings.read), + validateQuery(adminBookingFiltersSchema), + paginationMiddleware(), + controller.getAllBookings + ); + +/** + * @openapi + * /bookings/{bookingRefId}: + * get: + * tags: [Bookings] + * summary: Get a booking by its reference ID + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: bookingRefId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Booking details + * 401: + * description: Not authenticated + * 404: + * description: Booking not found + * delete: + * tags: [Bookings] + * summary: Cancel a booking by its reference ID + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: bookingRefId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Booking cancelled successfully + * 401: + * description: Not authenticated + * 404: + * description: Booking not found + */ +router + .route('/:bookingRefId') + .all(verifyAccessToken, validateParams(bookingRefIdParamSchema)) + .get(requirePermission(P.bookings.read), controller.getBookingById) + .delete(requirePermission(P.bookings.delete), controller.cancelBooking); + +export { router as bookingRouter }; diff --git a/server/src/modules/booking/booking.service.ts b/server/src/modules/booking/booking.service.ts new file mode 100644 index 0000000000..93a1397dff --- /dev/null +++ b/server/src/modules/booking/booking.service.ts @@ -0,0 +1,405 @@ +import type { Types } from 'mongoose'; +import { BookingStatus, type BookingStatusType } from '../../constants/booking.constants'; +import { PaymentStatus } from '../../constants/payment.constants'; +import { logError, logInfo, logWarn } from '../../utils/logger'; +import { issueRefund } from '../../services/razorpay.service'; +import { + markWebhookProcessed, + createBooking, + createFailedBooking, + findAllBookings, + findLockByIdRaw, + deleteLockById, + fetchMyBookings, + fetchBookingById, + fetchBookingByPaymentReference, + type AggregatedBooking, +} from './booking.repository'; +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import { minutesToTimeString } from '../../utils/timeUtils'; +import { + EmailIntent, + EmailTaskStatus, + type EmailIntentType, +} from '../../constants/email.constants'; +import type { ILock } from './lock.types'; +import type { IBooking } from './booking.types'; +import type { RazorpayWebhookNotes } from './booking.types'; +import { findUserEmailById } from '../user/user.repository'; +import { findVenueNameAndOwner, findVenueById } from '../venue/venue.repository'; +import { enqueueEmailTask } from '../../services/email.repository'; + +async function enqueueEmail( + intent: EmailIntentType, + recipient: string, + subject: string, + metadata: Record +): Promise { + try { + await enqueueEmailTask(recipient, intent, subject, EmailTaskStatus.PENDING, metadata); + } catch (err) { + logError('Failed to enqueue email task', { + module: 'booking.service.ts/enqueueEmail', + intent, + recipient, + error: (err as Error).message, + }); + } +} + +async function resolveCustomerEmail(userId: string): Promise { + try { + return await findUserEmailById(userId); + } catch { + return null; + } +} + +async function resolveVenueInfo( + venueId: string +): Promise<{ venueName: string; ownerEmail: string | null } | null> { + try { + const venue = await findVenueNameAndOwner(venueId); + if (!venue) return null; + + const ownerEmail = await findUserEmailById(venue.ownerUserId.toString()); + + return { + venueName: venue.name, + ownerEmail: ownerEmail, + }; + } catch { + return null; + } +} + +// Processes a captured payment webhook. +export async function processCapturedPayment( + paymentId: string, + amountPaise: number, + notes: RazorpayWebhookNotes, + paymentMethod?: string +): Promise<{ success: boolean; isDuplicate?: boolean; error?: string }> { + try { + await markWebhookProcessed(paymentId); + } catch (err) { + const error = err as { code?: number; message: string }; + if (error.code === 11000) { + logInfo('Duplicate webhook received — skipping (already processed)', { + module: 'booking.service.ts/processCapturedPayment', + eventId: paymentId, + }); + return { success: true, isDuplicate: true }; + } + logError('CRITICAL: Failed to record webhook idempotency marker', { + module: 'booking.service.ts/processCapturedPayment', + eventId: paymentId, + error: error.message, + }); + return { + success: false, + error: 'Idempotency gate error — refusing to process to prevent duplicate booking', + }; + } + + const { lockId, venueId, userId } = notes; + if (!lockId || !venueId || !userId) { + logError('Webhook payment captured notes are missing required fields', { + module: 'booking.service.ts/processCapturedPayment', + eventId: paymentId, + notes, + }); + return { success: false, error: 'Missing notes metadata' }; + } + + const [venueInfo, customerEmail] = await Promise.all([ + resolveVenueInfo(venueId), + resolveCustomerEmail(userId), + ]); + + const venueName = venueInfo?.venueName ?? 'the venue'; + const ownerEmail = venueInfo?.ownerEmail ?? null; + + let lock: (ILock & { _id: Types.ObjectId }) | null; + try { + lock = await findLockByIdRaw(lockId); + } catch (err) { + logError('Failed to query LockModel in webhook service', { + module: 'booking.service.ts/processCapturedPayment', + lockId, + error: (err as Error).message, + }); + return { success: false, error: 'Lock query failed' }; + } + + // Scenario A: Slot lock is active. Confirm the booking and delete the lock. + if (lock) { + const expectedAmountPaise = Math.round(lock.price * 100); + if (Math.abs(amountPaise - expectedAmountPaise) > 100) { + logWarn( + 'Captured payment amount does not match lock price — proceeding, but flagging for review', + { + module: 'booking.service.ts/processCapturedPayment', + lockId, + paymentId, + expectedAmountPaise, + capturedAmountPaise: amountPaise, + } + ); + } + + logInfo('Webhook Scenario A: Lock exists, creating booking', { + lockId, + venueId, + userId, + paymentId, + }); + + // Transfer contractSnapshot from lock to booking + let contractSnapshot = lock.contractSnapshot; + + if (!contractSnapshot) { + logWarn('Lock missing contractSnapshot — building fallback from venue', { + module: 'booking.service.ts/processCapturedPayment', + lockId, + venueId, + }); + const fallbackVenue = await findVenueById(venueId); + if (fallbackVenue) { + const totalPrice = amountPaise / 100; + contractSnapshot = { + venue: { + name: fallbackVenue.name, + city: fallbackVenue.city, + district: fallbackVenue.district, + }, + packages: [ + { + pkgName: `${String(lock.startTime)}-${String(lock.endTime)}`, + pkgType: fallbackVenue.bookingType === 'fixedBooking' ? 'fixed' : 'flexible', + startTime: lock.startTime, + endTime: lock.endTime, + price: totalPrice, + }, + ], + financial: { basePrice: totalPrice, taxes: 0, platformFee: 0, totalPaid: totalPrice }, + cancellation: fallbackVenue.cancellation, + }; + } + } + + // Security re-verify: fetch venue and log inconsistencies + try { + const currentVenue = await findVenueById(venueId); + if (currentVenue && contractSnapshot) { + if (currentVenue.name !== contractSnapshot.venue.name) { + logWarn('Venue name changed since lock was acquired', { + module: 'booking.service.ts/processCapturedPayment', + lockId, + venueId, + snapshotName: contractSnapshot.venue.name, + currentName: currentVenue.name, + }); + } + if (currentVenue.cancellation.policy !== contractSnapshot.cancellation.policy) { + logWarn('Venue cancellation policy changed since lock was acquired', { + module: 'booking.service.ts/processCapturedPayment', + lockId, + venueId, + snapshotPolicy: contractSnapshot.cancellation.policy, + currentPolicy: currentVenue.cancellation.policy, + }); + } + } + } catch (err) { + logWarn('Failed to re-verify venue during webhook processing', { + module: 'booking.service.ts/processCapturedPayment', + lockId, + venueId, + error: (err as Error).message, + }); + } + + try { + await createBooking({ + venueId, + userId, + date: lock.date, + startTime: lock.startTime, + endTime: lock.endTime, + price: amountPaise / 100, + paymentReference: paymentId, + status: BookingStatus.CONFIRMED, + paymentStatus: PaymentStatus.PAID, + guestCount: lock.guestCount, + eventType: lock.eventType, + bookerInfo: lock.bookerInfo, + paymentMethod: paymentMethod, + contractSnapshot, + }); + } catch (err) { + const error = err as { code?: number; message: string }; + if (error.code === 11000) { + logError('CRITICAL: Slot already booked (duplicate-key) — issuing automatic refund', { + module: 'booking.service.ts/processCapturedPayment', + lockId, + paymentId, + venueId, + userId, + }); + try { + await issueRefund(paymentId, amountPaise); + } catch (refundErr) { + logError('CRITICAL: Automatic refund failed after lost double-booking race', { + module: 'booking.service.ts/processCapturedPayment', + lockId, + paymentId, + error: (refundErr as Error).message, + }); + } + if (notes.lockId) { + await deleteLockById(notes.lockId).catch(() => undefined); + } + return { success: false, error: 'Slot already booked; refund issued' }; + } + logError('CRITICAL: Failed to create booking in Scenario A', { + module: 'booking.service.ts/processCapturedPayment', + eventId: paymentId, + error: (err as Error).message, + }); + return { success: false, error: 'Booking creation failed' }; + } + + try { + if (notes.lockId) { + await deleteLockById(notes.lockId); + } + } catch (err) { + logWarn('Failed to delete lock after booking creation', { + module: 'booking.service.ts/processCapturedPayment', + lockId, + error: (err as Error).message, + }); + } + + const emailMetadata = { + venueName, + date: lock.date, + startTime: minutesToTimeString(lock.startTime), + endTime: minutesToTimeString(lock.endTime), + amount: String(amountPaise / 100), + paymentReference: paymentId, + }; + + if (customerEmail) { + void enqueueEmail( + EmailIntent.BOOKING_CONFIRMATION, + customerEmail, + `Booking Confirmed – ${venueName} on ${lock.date}`, + emailMetadata + ); + } + + if (ownerEmail) { + void enqueueEmail( + EmailIntent.BOOKING_CONFIRMATION, + ownerEmail, + `New Booking Received – ${venueName} on ${lock.date}`, + emailMetadata + ); + } + + logInfo('Scenario A complete: booking confirmed', { lockId, paymentId }); + return { success: true }; + } + + // Scenario B: Slot lock has expired. Issue safety refund and log failed booking. + logWarn('Webhook Scenario B: Lock TTL expired — issuing safety refund', { + lockId, + venueId, + userId, + paymentId, + }); + + let refundId: string; + try { + const refundResult = await issueRefund(paymentId, amountPaise); + refundId = refundResult.refundId; + } catch (err) { + logError('CRITICAL: Razorpay refund failed in Scenario B', { + module: 'booking.service.ts/processCapturedPayment', + paymentId, + amountPaise, + error: (err as Error).message, + }); + return { success: false, error: 'Refund failed — manual review required' }; + } + + try { + await createFailedBooking({ + venueId, + userId, + date: 'UNKNOWN', + startTime: 0, + endTime: 0, + amountPaid: amountPaise, + paymentReference: paymentId, + refundReference: refundId, + reason: 'TTL_EXPIRED_COLLISION', + }); + } catch (err) { + logError('Failed to create FailedBooking audit record', { + module: 'booking.service.ts/processCapturedPayment', + paymentId, + refundId, + error: (err as Error).message, + }); + } + + if (customerEmail) { + void enqueueEmail( + EmailIntent.BOOKING_REFUND, + customerEmail, + `Booking Failed – Full Refund Initiated for ${venueName}`, + { + venueName, + date: 'your selected date', + startTime: 'your selected slot', + endTime: 'your selected slot', + amount: String(amountPaise / 100), + refundReference: refundId, + } + ); + } + + logInfo('Scenario B complete: refund issued', { paymentId, refundId }); + return { success: true }; +} + +export async function getAllBookings( + paginationParams: PaginationParams, + filters?: { status?: BookingStatusType; venueId?: string } +): Promise> { + return findAllBookings(paginationParams, filters); +} + +export async function getMyBookings(userId: string): Promise<{ + bookings: { + upcoming: Record[]; + cancelled: Record[]; + completed: Record[]; + }; +}> { + return fetchMyBookings(userId); +} + +export async function getBookingById( + bookingId: string, + userId: string +): Promise | null> { + return fetchBookingById(bookingId, userId); +} + +export async function getBookingByPaymentReference(paymentId: string): Promise { + return fetchBookingByPaymentReference(paymentId); +} diff --git a/server/src/modules/booking/booking.types.ts b/server/src/modules/booking/booking.types.ts new file mode 100644 index 0000000000..c199f77c61 --- /dev/null +++ b/server/src/modules/booking/booking.types.ts @@ -0,0 +1,117 @@ +import type { Document, Types } from 'mongoose'; +import type { BookingStatusType } from '../../constants/booking.constants'; +import type { PaymentStatusType } from '../../constants/payment.constants'; +import type { IContractSnapshot } from './lock.types'; + +export type BookingStatus = BookingStatusType; + +export interface RazorpayWebhookNotes { + lockId?: string; + venueId?: string; + userId?: string; +} + +export interface IBooking extends Document { + venueId: Types.ObjectId; + userId: Types.ObjectId; + date: string; // YYYY-MM-DD + startTime: number; // minutes from midnight + endTime: number; // minutes from midnight + price: number; + paymentReference: string; // Razorpay payment_id + status: BookingStatus; + paymentStatus: PaymentStatusType; + guestCount?: number; + eventType?: string; + bookerInfo?: { + name?: string; + email?: string; + phone?: string; + place?: string; + note?: string; + }; + paymentMethod?: string; + contractSnapshot?: IContractSnapshot; + + // Future update fields (Not needed for MVP now) + advancePaid?: number; + remainingAmount?: number; + cancellationReason?: string; + + createdAt: Date; + updatedAt: Date; +} + +// Aggregated booking for admin/owner queries with populated refs +export interface AggregatedBooking { + _id: Types.ObjectId; + userId: Types.ObjectId; + venueId: Types.ObjectId; + date: string; + startTime: number; + endTime: number; + price: number; + paymentReference: string; + status: BookingStatusType; + paymentStatus: PaymentStatusType; + guestCount?: number; + eventType?: string; + bookerInfo?: { + name?: string; + email?: string; + phone?: string; + place?: string; + note?: string; + }; + paymentMethod?: string; + advancePaid?: number; + remainingAmount?: number; + createdAt: Date; + updatedAt: Date; + contractSnapshot?: IContractSnapshot; + venue: { + _id: Types.ObjectId; + name: string; + city: string; + district: string; + coverImage: string; + address: string; + contact: { + phone: string; + email?: string; + }; + googleMapsUrl?: string; + amenities: string[]; + }; + // User who made the booking (for admin view) + user?: { + _id: Types.ObjectId; + username: string; + email: string; + phone?: string; + status: string; + }; +} + +// Computed UI status for display +export type UIBookingStatus = 'confirmed' | 'completed' | 'cancelled'; + +// FailedBooking (No TTL - usefull later) +export interface IFailedBooking extends Document { + venueId: Types.ObjectId; + userId: Types.ObjectId; + date: string; + startTime: number; + endTime: number; + amountPaid: number; // in paise + paymentReference: string; // Razorpay payment_id + refundReference: string; // Razorpay refund_id + reason: string; // e.g: 'TTL_EXPIRED_COLLISION' + createdAt: Date; +} + +// ProcessedWebhook +export interface IProcessedWebhook extends Document { + eventId: string; + createdAt: Date; +} diff --git a/server/src/modules/booking/booking.validator.ts b/server/src/modules/booking/booking.validator.ts new file mode 100644 index 0000000000..af191b980f --- /dev/null +++ b/server/src/modules/booking/booking.validator.ts @@ -0,0 +1,280 @@ +import { z } from 'zod'; +import type { IVenue } from '../venue/venue.types'; +import { timeStringToMinutes, isBookableDate } from '../../utils/timeUtils'; + +export const venueIdParamSchema = z.object({ + id: z + .string() + .trim() + .regex(/^[a-f\d]{24}$/i, 'Invalid venue ID format'), +}); + +export type VenueIdParamDTO = z.infer; + +const selectedSlotSchema = z + .object({ + startTime: z.number().int().min(0).max(1439), + endTime: z.number().int().min(1).max(1440), + }) + .refine((data) => data.startTime < data.endTime, { + message: 'endTime must be strictly greater than startTime', + path: ['endTime'], + }); + +export const blockSlotBodySchema = z.object({ + date: z + .string() + .trim() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be in YYYY-MM-DD format') + .refine(isBookableDate, { + message: 'Date must be between tomorrow and 90 days from today', + }), + selectedSlots: z + .array(selectedSlotSchema) + .min(1, 'At least one slot must be selected') + .max(24, 'Cannot select more than 24 slots at once'), + expectedPrice: z.number().positive('expectedPrice must be greater than 0'), +}); + +export type BlockSlotBodyDTO = z.infer; + +export function validateDateForVenue( + venue: IVenue, + date: string +): { valid: boolean; reason?: string } { + const dateObj = new Date(date + 'T00:00:00'); + const dayOfWeek = dateObj.toLocaleDateString('en-US', { weekday: 'long' }); + + if (!venue.workingDays.includes(dayOfWeek)) { + return { valid: false, reason: `Venue is not open on ${dayOfWeek}s.` }; + } + + const isBlocked = venue.blockedDates.some( + (blockedDate) => blockedDate.toISOString().split('T')[0] === date + ); + if (isBlocked) { + return { valid: false, reason: 'This date is blocked by the venue.' }; + } + + // Check temporary block + if (venue.temporaryBlockAfterDate) { + const blockDate = new Date(venue.temporaryBlockAfterDate); + dateObj.setHours(0, 0, 0, 0); + blockDate.setHours(0, 0, 0, 0); + if (dateObj >= blockDate) { + return { valid: false, reason: "This date is after the venue's temporary booking block" }; + } + } + + // Check inactivity block + if (venue.inactivity?.blockedAfterDate) { + const blockDate = new Date(venue.inactivity.blockedAfterDate); + dateObj.setHours(0, 0, 0, 0); + blockDate.setHours(0, 0, 0, 0); + if (dateObj >= blockDate) { + return { valid: false, reason: "This date falls within the venue's closing period" }; + } + } + + return { valid: true }; +} + +export function validateSelectedSlotsForVenue( + venue: IVenue, + selectedSlots: { startTime: number; endTime: number }[] +): { valid: boolean; reason?: string } { + // Guard against overlapping selected slots + const sortedSlots = [...selectedSlots].sort((a, b) => a.startTime - b.startTime); + for (let i = 0; i < sortedSlots.length - 1; i++) { + if (sortedSlots[i].endTime > sortedSlots[i + 1].startTime) { + return { valid: false, reason: 'Selected slots cannot overlap each other.' }; + } + } + + if (venue.bookingType === 'fixedBooking') { + for (const slot of selectedSlots) { + const match = (venue.fixedPackages ?? []).find( + (pkg) => + timeStringToMinutes(pkg.startTime) === slot.startTime && + timeStringToMinutes(pkg.endTime) === slot.endTime + ); + if (!match) { + return { + valid: false, + reason: 'One or more selected slots do not match any fixed package.', + }; + } + } + } else { + // flexibleBooking + if (!venue.workingHours) { + return { valid: false, reason: 'Venue working hours are not configured.' }; + } + const openMin = timeStringToMinutes(venue.workingHours.open); + const closeMin = timeStringToMinutes(venue.workingHours.close); + const duration = venue.flexibleBooking?.slotDuration ?? 60; + const buffer = venue.flexibleBooking?.bufferTime ?? 0; + const cycle = duration + buffer; + + for (const slot of selectedSlots) { + if (slot.startTime < openMin || slot.endTime > closeMin) { + return { valid: false, reason: 'One or more slots are outside venue working hours.' }; + } + if (slot.endTime - slot.startTime !== duration) { + return { + valid: false, + reason: `All slots must be exactly ${String(duration)} minutes long.`, + }; + } + if ((slot.startTime - openMin) % cycle !== 0) { + return { + valid: false, + reason: "Slot times do not align with the venue's scheduling grid.", + }; + } + } + + // Must be contiguous + for (let i = 0; i < sortedSlots.length - 1; i++) { + if (sortedSlots[i].endTime + buffer !== sortedSlots[i + 1].startTime) { + return { valid: false, reason: 'Multiple flexible slots must be contiguous.' }; + } + } + } + + return { valid: true }; +} + +export function computeServerTotalPrice( + venue: IVenue, + selectedSlots: { startTime: number; endTime: number }[] +): number { + let totalPrice = 0; + + if (venue.bookingType === 'fixedBooking') { + for (const slot of selectedSlots) { + const match = (venue.fixedPackages ?? []).find( + (pkg) => + timeStringToMinutes(pkg.startTime) === slot.startTime && + timeStringToMinutes(pkg.endTime) === slot.endTime + ); + if (match) totalPrice += match.price; + } + } else { + // flexibleBooking + if (!venue.pricing) { + throw new Error('Pricing configuration is missing for flexible booking venue'); + } + for (const slot of selectedSlots) { + let appliedPrice = venue.pricing.basePrice; + if (venue.pricing.pricingType === 'timeBasedPricing') { + for (const rule of venue.pricing.pricingRules) { + const ruleStart = timeStringToMinutes(rule.fromTime); + const ruleEnd = timeStringToMinutes(rule.toTime); + if (slot.startTime >= ruleStart && slot.startTime < ruleEnd) { + appliedPrice = rule.price; + break; + } + } + } + totalPrice += appliedPrice; + } + } + + return totalPrice; +} + +export function assertPriceWithinTolerance( + serverPrice: number, + clientPrice: number, + tolerancePct = 2 +): { valid: boolean; reason?: string } { + if (serverPrice <= 0) { + return { + valid: false, + reason: 'Server could not compute a valid price for this slot. Please contact support.', + }; + } + const drift = Math.abs(serverPrice - clientPrice) / serverPrice; + if (drift > tolerancePct / 100) { + return { + valid: false, + reason: `Price mismatch. Expected ₹${String(serverPrice)} but received ₹${String(clientPrice)}. Please refresh and try again.`, + }; + } + return { valid: true }; +} + +export const checkoutBodySchema = z.object({ + lockId: z + .string() + .trim() + .regex(/^[a-f\d]{24}$/i, 'Invalid lockId format'), +}); + +export type CheckoutBodyDTO = z.infer; + +const bookerInfoSchema = z.object({ + name: z.string().trim().min(1, 'Name is required'), + email: z + .string() + .trim() + .regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Valid email is required'), + phone: z.string().trim().min(10, 'Valid phone number is required'), + place: z.string().trim().min(1, 'Place is required'), + note: z.string().trim().optional(), +}); + +export const saveBookerDetailsBodySchema = z.object({ + lockId: z + .string() + .trim() + .regex(/^[a-f\d]{24}$/i, 'Invalid lockId format'), + guestCount: z.number().int().positive('Guest count must be positive'), + eventType: z.string().trim().min(1, 'Event type is required').max(100, 'Event type is too long'), + bookerInfo: bookerInfoSchema, +}); + +export type SaveBookerDetailsBodyDTO = z.infer; + +export const verifyPaymentBodySchema = z.object({ + orderId: z.string().trim().min(1, 'orderId is required'), + paymentId: z.string().trim().min(1, 'paymentId is required'), + signature: z.string().trim().min(1, 'razorpay_signature is required'), +}); + +export type VerifyPaymentBodyDTO = z.infer; + +export const fetchMyBookingsQuerySchema = z.object({}); +export type FetchMyBookingsQueryDTO = z.infer; + +export const adminBookingFiltersSchema = z.object({ + status: z.enum(['confirmed', 'pending', 'cancelled', 'completed', 'in_progress']).optional(), + venueId: z + .string() + .trim() + .regex(/^[a-f\d]{24}$/i, 'Invalid venue ID format') + .optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), + sort: z.string().optional(), +}); + +export type AdminBookingFiltersDTO = z.infer; + +export const bookingRefIdParamSchema = z.object({ + bookingRefId: z + .string() + .trim() + .refine( + (val) => { + const isObjectId = /^[a-f\d]{24}$/i.test(val); + const isBookingRef = val.toUpperCase().startsWith('BMV-') && val.length === 10; + return isObjectId || isBookingRef; + }, + { + message: 'Invalid booking ID or reference format', + } + ), +}); +export type BookingRefIdParamDTO = z.infer; diff --git a/server/src/modules/booking/booking.workflow.ts b/server/src/modules/booking/booking.workflow.ts new file mode 100644 index 0000000000..81891fa054 --- /dev/null +++ b/server/src/modules/booking/booking.workflow.ts @@ -0,0 +1,409 @@ +import crypto from 'crypto'; +import { Types } from 'mongoose'; +import { runInTransaction } from '../../utils/dbUtils'; +import { acquireSlotMutex, releaseSlotMutex } from '../../utils/mutex'; +import { checkOverlap, timeStringToMinutes } from '../../utils/timeUtils'; +import { logInfo, logWarn, logError } from '../../utils/logger'; + +import * as repo from './booking.repository'; +import { + validateDateForVenue, + validateSelectedSlotsForVenue, + computeServerTotalPrice, + assertPriceWithinTolerance, +} from './booking.validator'; +import { issueRefund, createOrder } from '../../services/razorpay.service'; +import { EmailIntent, EmailTaskStatus } from '../../constants/email.constants'; +import { enqueueEmailTask } from '../../services/email.repository'; +import { findUserEmailById } from '../user/user.repository'; +import { BookingStatus } from '../../constants/booking.constants'; +import type { IContractSnapshot, IContractPackage } from './lock.types'; + +const LOCK_TTL_SECONDS = 600; +const CHECKOUT_BUFFER_MS = 60 * 1000; + +function buildContractSnapshot( + venue: { + name: string; + city: string; + district: string; + bookingType: string; + fixedPackages?: { slotName: string; startTime: string; endTime: string; price: number }[]; + pricing?: { + basePrice: number; + pricingType: string; + pricingRules: { fromTime: string; toTime: string; price: number }[]; + }; + cancellation?: { + policy: 'refundable' | 'nonRefundable'; + refundType?: 'fullRefund' | 'timeBasedRefund'; + refundRules: { daysBefore: number; refundPercentage: number }[]; + }; + }, + selectedSlots: { startTime: number; endTime: number }[], + serverPrice: number +): IContractSnapshot { + const packages: IContractPackage[] = []; + + if (venue.bookingType === 'fixedBooking') { + for (const slot of selectedSlots) { + const pkg = (venue.fixedPackages ?? []).find( + (p) => + timeStringToMinutes(p.startTime) === slot.startTime && + timeStringToMinutes(p.endTime) === slot.endTime + ); + packages.push({ + pkgName: pkg?.slotName ?? `${String(slot.startTime)}-${String(slot.endTime)}`, + pkgType: 'fixed', + startTime: slot.startTime, + endTime: slot.endTime, + price: pkg?.price ?? 0, + }); + } + } else { + if (!venue.pricing) { + throw new Error('Pricing configuration is missing for flexible booking venue'); + } + for (const slot of selectedSlots) { + let price = venue.pricing.basePrice; + if (venue.pricing.pricingType === 'timeBasedPricing') { + for (const rule of venue.pricing.pricingRules) { + const ruleStart = timeStringToMinutes(rule.fromTime); + const ruleEnd = timeStringToMinutes(rule.toTime); + if (slot.startTime >= ruleStart && slot.startTime < ruleEnd) { + price = rule.price; + break; + } + } + } + packages.push({ + pkgName: `${String(slot.startTime)}-${String(slot.endTime)}`, + pkgType: 'flexible', + startTime: slot.startTime, + endTime: slot.endTime, + price, + }); + } + } + + if (!venue.cancellation) { + logError('Venue missing cancellation policy', { + module: 'booking.workflow.ts/buildContractSnapshot', + venueName: venue.name, + }); + } + + return { + venue: { + name: venue.name, + city: venue.city, + district: venue.district, + }, + packages, + financial: { + basePrice: serverPrice, + taxes: 0, + platformFee: 0, + totalPaid: serverPrice, + }, + cancellation: venue.cancellation ?? { + policy: 'nonRefundable' as const, + refundRules: [], + }, + }; +} + +export async function blockSlotWorkflow( + venueId: string, + userId: string, + date: string, + expectedPrice: number, + selectedSlots: { startTime: number; endTime: number }[], + sessionToken?: string +): Promise<{ lockId: string; expiresAt: string; amountToPay: number }> { + const venue = await repo.fetchApprovedVenueForBooking(venueId); + if (!venue) { + throw new Error('Venue not found or unavailable for booking.'); + } + + const dateCheck = validateDateForVenue(venue, date); + if (!dateCheck.valid) { + throw new Error(dateCheck.reason ?? 'Invalid date'); + } + + const slotCheck = validateSelectedSlotsForVenue(venue, selectedSlots); + if (!slotCheck.valid) { + throw new Error(slotCheck.reason ?? 'Invalid slots'); + } + + const serverPrice = computeServerTotalPrice(venue, selectedSlots); + const priceCheck = assertPriceWithinTolerance(serverPrice, expectedPrice, 2); + if (!priceCheck.valid) { + throw new Error(priceCheck.reason ?? 'Price mismatch'); + } + + const contractSnapshot = buildContractSnapshot(venue, selectedSlots, serverPrice); + + const sortedSlots = [...selectedSlots].sort((a, b) => a.startTime - b.startTime); + const effectiveStart = sortedSlots[0].startTime; + const effectiveEnd = sortedSlots[sortedSlots.length - 1].endTime; + + const sessionTokenHash = sessionToken + ? crypto.createHash('sha256').update(sessionToken).digest('hex') + : undefined; + + const mutexAcquired = await acquireSlotMutex(venueId, date); + if (!mutexAcquired) { + throw new Error('Someone else is booking this slot right now. Please try again in a moment.'); + } + + let lock; + try { + lock = await runInTransaction(async (session) => { + const conflicts = await repo.fetchActiveConflicts(venueId, date, { + excludeUserId: userId, + excludeSessionTokenHash: sessionTokenHash, + session, + }); + + if (checkOverlap(effectiveStart, effectiveEnd, conflicts)) { + throw new Error( + 'The selected time slot is no longer available. Please choose a different slot.' + ); + } + + const deleteConditions: Record[] = [{ userId: new Types.ObjectId(userId) }]; + if (sessionTokenHash) { + deleteConditions.push({ sessionTokenHash }); + } + await repo.deleteLocksByConditions( + [ + { userId: new Types.ObjectId(userId) }, + ...(sessionTokenHash ? [{ sessionTokenHash }] : []), + ], + session + ); + + const [newLock] = await repo.createLock( + [ + { + venueId: new Types.ObjectId(venueId), + userId: new Types.ObjectId(userId), + date, + startTime: effectiveStart, + endTime: effectiveEnd, + price: serverPrice, + sessionTokenHash, + contractSnapshot, + createdAt: new Date(), + }, + ], + session + ); + return newLock; + }); + } finally { + await releaseSlotMutex(venueId, date); + } + + const expiresAt = new Date(lock.createdAt.getTime() + LOCK_TTL_SECONDS * 1000); + + logInfo('Slot lock acquired', { + lockId: lock._id.toString(), + venueId, + userId, + date, + startTime: effectiveStart, + endTime: effectiveEnd, + }); + + return { + lockId: lock._id.toString(), + expiresAt: expiresAt.toISOString(), + amountToPay: serverPrice, + }; +} + +export async function saveBookerDetailsWorkflow( + lockId: string, + userId: string, + guestCount?: number, + eventType?: string, + bookerInfo?: Record +): Promise { + const lock = await repo.updateLockBookerDetails( + lockId, + userId, + guestCount, + eventType, + bookerInfo + ); + if (!lock) { + throw new Error('Booking session expired. Please re-select your slot and try again.'); + } + return true; +} + +export async function initCheckoutWorkflow( + userId: string, + lockId: string +): Promise<{ orderId: string; amount: number; currency: string }> { + const lock = await repo.findLockById(lockId, userId); + + if (!lock) { + throw new Error('Lock expired. Please re-select your slot and try again.'); + } + + const lockCreatedAt = + lock.createdAt instanceof Date ? lock.createdAt.getTime() : new Date(lock.createdAt).getTime(); + + const lockExpiresAtMs = lockCreatedAt + LOCK_TTL_SECONDS * 1000; + const remainingMs = lockExpiresAtMs - Date.now(); + + if (remainingMs < CHECKOUT_BUFFER_MS) { + logWarn('Checkout rejected: insufficient time remaining on lock', { + lockId, + userId, + remainingSeconds: Math.floor(remainingMs / 1000), + }); + throw new Error('Insufficient time to complete payment. Please re-select your slot.'); + } + + const amountPaise = Math.round(lock.price * 100); + + const order = await createOrder({ + amountPaise, + currency: 'INR', + notes: { + lockId: lock._id.toString(), + venueId: lock.venueId.toString(), + userId: lock.userId.toString(), + }, + }); + + logInfo('Razorpay checkout order created', { + lockId: lock._id.toString(), + orderId: order.orderId, + amount: order.amount, + }); + + return { + orderId: order.orderId, + amount: order.amount, + currency: order.currency, + }; +} + +export async function releaseLockWorkflow(userId?: string, sessionToken?: string): Promise { + if (!userId && !sessionToken) return; + + const deleteConditions: Record[] = []; + if (userId) deleteConditions.push({ userId: new Types.ObjectId(userId) }); + if (sessionToken) { + const sessionTokenHash = crypto.createHash('sha256').update(sessionToken).digest('hex'); + deleteConditions.push({ sessionTokenHash }); + } + + await repo.deleteLocksByConditions(deleteConditions); +} + +export async function cancelBookingWorkflow( + userId: string, + bookingId: string, + reason?: string +): Promise { + const booking = await repo.fetchUserBookingByRefIdOrId(bookingId, userId); + + if (!booking) { + throw new Error('Booking not found'); + } + + if (booking.status !== BookingStatus.CONFIRMED) { + throw new Error('Only confirmed bookings can be cancelled'); + } + + const now = new Date(); + const bookingEnd = new Date(`${booking.date}T00:00:00`); + bookingEnd.setMinutes(booking.endTime); + + if (bookingEnd < now) { + throw new Error('Cannot cancel a booking that has already started or completed'); + } + + const bookingDetails = await repo.fetchBookingById(booking._id.toString(), userId); + if (!bookingDetails || (bookingDetails.cancellationRefundPct as number) === 0) { + throw new Error('Cancellation window passed or non-refundable venue'); + } + + const hasSnapshot = bookingDetails.contractSnapshot !== undefined; + if (!hasSnapshot) { + logWarn('Booking missing contractSnapshot — falling back to live venue data', { + module: 'booking.workflow.ts/cancelBookingWorkflow', + bookingId: booking._id.toString(), + userId, + }); + } + + // Atomic guard: only one concurrent cancellation request can ever pass + // this point for a given booking. If another request already cancelled + // it (race or retry), this returns null and we stop before touching + // Razorpay — this is what prevents a double refund. + const claimed = await repo.claimBookingForCancellation(booking._id.toString(), userId, reason); + if (!claimed) { + throw new Error('This booking has already been cancelled.'); + } + + const amountPaise = claimed.price * 100; + const refundAmountPaise = Math.floor( + amountPaise * ((bookingDetails.cancellationRefundPct as number) / 100) + ); + + if (refundAmountPaise > 0 && claimed.paymentReference) { + try { + await issueRefund(claimed.paymentReference, refundAmountPaise); + } catch (err) { + logError('CRITICAL: Razorpay refund failed after booking was already marked cancelled', { + module: 'booking.workflow.ts/cancelBookingWorkflow', + bookingId: claimed._id.toString(), + paymentId: claimed.paymentReference, + amountPaise: refundAmountPaise, + error: (err as Error).message, + }); + throw new Error('Booking cancelled, but the refund failed. Please contact support.', { + cause: err, + }); + } + } + + try { + const customerEmail = await findUserEmailById(userId); + + if (customerEmail) { + const venueName = bookingDetails.venueName as string; + const date = bookingDetails.date as string; + const timeRange = bookingDetails.timeRange as string; + const bookingRef = bookingDetails.bookingRef as string; + + await enqueueEmailTask( + customerEmail, + EmailIntent.BOOKING_CANCELLATION, + `Booking Cancelled – ${venueName}`, + EmailTaskStatus.PENDING, + { + bookingRef, + venueName, + date, + timeRange, + refundAmount: (refundAmountPaise / 100).toString(), + } + ); + } + } catch (emailErr) { + logWarn('Failed to queue cancellation email', { + bookingId, + error: (emailErr as Error).message, + }); + } + + return true; +} diff --git a/server/src/modules/booking/lock.types.ts b/server/src/modules/booking/lock.types.ts new file mode 100644 index 0000000000..c8145dda91 --- /dev/null +++ b/server/src/modules/booking/lock.types.ts @@ -0,0 +1,57 @@ +import type { Document, Types } from 'mongoose'; + +export interface IContractPackage { + pkgName: string; + pkgType: 'fixed' | 'flexible'; + startTime: number; + endTime: number; + price: number; +} + +export interface IContractFinancial { + basePrice: number; + taxes: number; + platformFee: number; + totalPaid: number; +} + +export interface IContractCancellation { + policy: 'refundable' | 'nonRefundable'; + refundType?: 'fullRefund' | 'timeBasedRefund'; + refundRules: { + daysBefore: number; + refundPercentage: number; + }[]; +} + +export interface IContractSnapshot { + venue: { + name: string; + city: string; + district: string; + }; + packages: IContractPackage[]; + financial: IContractFinancial; + cancellation: IContractCancellation; +} + +export interface ILock extends Document { + venueId: Types.ObjectId; + userId: Types.ObjectId; + date: string; + startTime: number; + endTime: number; + price: number; + sessionTokenHash?: string; + guestCount?: number; + eventType?: string; + bookerInfo?: { + name?: string; + email?: string; + phone?: string; + place?: string; + note?: string; + }; + contractSnapshot?: IContractSnapshot; + createdAt: Date; +} diff --git a/server/src/modules/booking/models/booking.model.ts b/server/src/modules/booking/models/booking.model.ts new file mode 100644 index 0000000000..76f7580a94 --- /dev/null +++ b/server/src/modules/booking/models/booking.model.ts @@ -0,0 +1,63 @@ +import mongoose, { Schema } from 'mongoose'; +import type { IBooking } from '../booking.types'; +import { BookingStatus } from '../../../constants/booking.constants'; +import { PaymentStatus } from '../../../constants/payment.constants'; + +const BookingSchema = new Schema( + { + venueId: { type: Schema.Types.ObjectId, ref: 'Venues', required: true }, + userId: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + date: { type: String, required: true }, + startTime: { type: Number, required: true }, // minutes from midnight + endTime: { type: Number, required: true }, // minutes from midnight + price: { type: Number, required: true }, + paymentReference: { type: String, required: true, trim: true }, // Razorpay payment_id + status: { + type: String, + enum: Object.values(BookingStatus), + required: true, + default: BookingStatus.CONFIRMED, + }, + paymentStatus: { + type: String, + enum: Object.values(PaymentStatus), + required: true, + default: PaymentStatus.PAID, + }, + guestCount: { type: Number, required: false }, + eventType: { type: String, required: false, trim: true }, + bookerInfo: { + name: { type: String, required: false, trim: true }, + email: { type: String, required: false, trim: true }, + phone: { type: String, required: false, trim: true }, + place: { type: String, required: false, trim: true }, + note: { type: String, required: false, trim: true }, + }, + paymentMethod: { type: String, required: false, trim: true }, + advancePaid: { type: Number, required: false }, + remainingAmount: { type: Number, required: false }, + cancellationReason: { type: String, required: false, trim: true }, + contractSnapshot: { type: Schema.Types.Mixed }, // typed via IContractSnapshot from lock.types + }, + { timestamps: true } +); + +BookingSchema.index({ venueId: 1, date: 1 }); + +BookingSchema.index({ venueId: 1, date: 1, startTime: 1, endTime: 1, status: 1 }); + +BookingSchema.index({ paymentStatus: 1 }); + +BookingSchema.index( + { venueId: 1, date: 1, startTime: 1, endTime: 1 }, + { + unique: true, + partialFilterExpression: { + status: { + $in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED, BookingStatus.IN_PROGRESS], + }, + }, + } +); + +export const BookingModel = mongoose.model('Bookings', BookingSchema, 'Bookings'); diff --git a/server/src/modules/booking/models/failedBooking.model.ts b/server/src/modules/booking/models/failedBooking.model.ts new file mode 100644 index 0000000000..d6ec610d3a --- /dev/null +++ b/server/src/modules/booking/models/failedBooking.model.ts @@ -0,0 +1,25 @@ +import mongoose, { Schema } from 'mongoose'; +import type { IFailedBooking } from '../booking.types'; + +const FailedBookingSchema = new Schema( + { + venueId: { type: Schema.Types.ObjectId, ref: 'Venues', required: true }, + userId: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + date: { type: String, required: true }, + startTime: { type: Number, required: true }, + endTime: { type: Number, required: true }, + amountPaid: { type: Number, required: true }, // paise + paymentReference: { type: String, required: true, trim: true }, + refundReference: { type: String, required: true, trim: true }, + reason: { type: String, required: true, trim: true }, // e.g. 'TTL_EXPIRED_COLLISION' + }, + { + timestamps: { createdAt: true, updatedAt: false }, // immutable audit log + } +); + +export const FailedBookingModel = mongoose.model( + 'FailedBookings', + FailedBookingSchema, + 'FailedBookings' +); diff --git a/server/src/modules/booking/models/lock.model.ts b/server/src/modules/booking/models/lock.model.ts new file mode 100644 index 0000000000..fc5121b788 --- /dev/null +++ b/server/src/modules/booking/models/lock.model.ts @@ -0,0 +1,27 @@ +import mongoose, { Schema } from 'mongoose'; +import type { ILock } from '../lock.types'; + +const LockSchema = new Schema({ + venueId: { type: Schema.Types.ObjectId, ref: 'Venues', required: true }, + userId: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + date: { type: String, required: true }, + startTime: { type: Number, required: true }, + endTime: { type: Number, required: true }, + price: { type: Number, required: true }, + sessionTokenHash: { type: String }, + guestCount: { type: Number, required: false }, + eventType: { type: String, required: false, trim: true }, + bookerInfo: { + name: { type: String, required: false, trim: true }, + email: { type: String, required: false, trim: true }, + phone: { type: String, required: false, trim: true }, + place: { type: String, required: false, trim: true }, + note: { type: String, required: false, trim: true }, + }, + contractSnapshot: { type: Schema.Types.Mixed }, // typed via ILock, Mixed avoids subdoc casting issues + createdAt: { type: Date, default: Date.now, expires: 600 }, // 600s = 10 minutes +}); + +LockSchema.index({ venueId: 1, date: 1 }); + +export const LockModel = mongoose.model('Locks', LockSchema, 'Locks'); diff --git a/server/src/modules/booking/models/processedWebhook.model.ts b/server/src/modules/booking/models/processedWebhook.model.ts new file mode 100644 index 0000000000..58089c5134 --- /dev/null +++ b/server/src/modules/booking/models/processedWebhook.model.ts @@ -0,0 +1,29 @@ +import mongoose, { Schema } from 'mongoose'; +import type { IProcessedWebhook } from '../booking.types'; + +const ProcessedWebhookSchema = new Schema( + { + // Razorpay event_id — unique index is the idempotency gate. + // Duplicate inserts throw MongoServerError code 11000, which the + // webhook controller catches to return 200 immediately. + eventId: { + type: String, + required: true, + unique: true, + trim: true, + }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, + } +); + +// Auto-expire records after 30 days to avoid unbounded collection growth. +// A 30-day window is sufficient; Razorpay never replays events that old. +ProcessedWebhookSchema.index({ createdAt: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 }); + +export const ProcessedWebhookModel = mongoose.model( + 'ProcessedWebhooks', + ProcessedWebhookSchema, + 'ProcessedWebhooks' +); diff --git a/server/src/modules/booking/models/slotMutex.model.ts b/server/src/modules/booking/models/slotMutex.model.ts new file mode 100644 index 0000000000..7196859793 --- /dev/null +++ b/server/src/modules/booking/models/slotMutex.model.ts @@ -0,0 +1,21 @@ +import mongoose, { Schema } from 'mongoose'; + +export interface ISlotMutex { + venueId: mongoose.Types.ObjectId; + date: string; + lockedAt: Date; +} + +const SlotMutexSchema = new Schema({ + venueId: { type: Schema.Types.ObjectId, required: true }, + date: { type: String, required: true }, + lockedAt: { type: Date, required: true, default: Date.now, expires: 30 }, +}); + +SlotMutexSchema.index({ venueId: 1, date: 1 }, { unique: true }); + +export const SlotMutexModel = mongoose.model( + 'SlotMutexes', + SlotMutexSchema, + 'SlotMutexes' +); diff --git a/server/src/modules/geo/geo.router.ts b/server/src/modules/geo/geo.router.ts new file mode 100644 index 0000000000..e764d86ee4 --- /dev/null +++ b/server/src/modules/geo/geo.router.ts @@ -0,0 +1,88 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { searchPlace } from './geo.service'; +import { ResponseUtil } from '../../utils/responseUtils'; +import { logError } from '../../utils/logger'; + +const router: Router = Router(); + +/** + * @openapi + * /geo/search: + * get: + * tags: [Geo] + * summary: Search for places using OpenStreetMap Nominatim + * description: | + * Search for cities, towns, or places in India using OpenStreetMap's Nominatim geocoding service. + * Results include coordinates, city/district, and postcode information. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: q + * required: true + * schema: + * type: string + * minLength: 2 + * description: Search query (city, town, or place name) + * responses: + * 200: + * description: Array of search results + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: array + * items: + * type: object + * properties: + * displayName: + * type: string + * lat: + * type: number + * lng: + * type: number + * city: + * type: string + * district: + * type: string + * postcode: + * type: string + * 400: + * description: Missing or invalid query parameter + * 401: + * description: Not authenticated + * 503: + * description: Search service temporarily unavailable + */ +router.get('/search', verifyAccessToken, async (req: Request, res: Response): Promise => { + try { + const { q } = req.query; + + if (!q || typeof q !== 'string' || q.trim().length < 2) { + ResponseUtil.badRequest(res, 'Search query must be at least 2 characters'); + return; + } + + const results = await searchPlace(q); + ResponseUtil.success(res, 'Places found', results); + } catch (err) { + const error = err as Error; + if (error.message.includes('temporarily unavailable')) { + ResponseUtil.internalServerError(res, error.message); + } else { + logError('Geo search error', { + module: 'geo.router.ts/search', + error: error.message, + }); + ResponseUtil.internalServerError(res, 'Search failed. Please try again.'); + } + } +}); + +export { router as geoRouter }; diff --git a/server/src/modules/geo/geo.service.ts b/server/src/modules/geo/geo.service.ts new file mode 100644 index 0000000000..aa4af0656a --- /dev/null +++ b/server/src/modules/geo/geo.service.ts @@ -0,0 +1,182 @@ +import { logError } from '../../utils/logger'; +import type { GeoSearchResult, NominatimResponse } from './geo.types'; + +// Simple in-memory LRU cache with TTL +interface CacheEntry { + data: T; + timestamp: number; +} + +class LRUCache { + private map = new Map>(); + private ttl: number; // ms + private maxSize: number; + + constructor(ttl: number = 5 * 60 * 1000, maxSize = 100) { + this.ttl = ttl; + this.maxSize = maxSize; + } + + get(key: string): T | null { + const entry = this.map.get(key); + if (!entry) return null; + + const now = Date.now(); + if (now - entry.timestamp > this.ttl) { + this.map.delete(key); + return null; + } + + // Move to end (most recently used) + this.map.delete(key); + this.map.set(key, entry); + return entry.data; + } + + set(key: string, value: T): void { + // Remove if exists to move to end + if (this.map.has(key)) { + this.map.delete(key); + } + + // Evict oldest if at capacity + if (this.map.size >= this.maxSize) { + const oldestKey = this.map.keys().next().value; + if (oldestKey) { + this.map.delete(oldestKey); + } + } + + this.map.set(key, { data: value, timestamp: Date.now() }); + } + + clear(): void { + this.map.clear(); + } +} + +// Token bucket rate limiter (1 request per second) +class TokenBucket { + private tokens = 1; + private maxTokens = 1; + private refillRate = 1000; // ms per token + private lastRefillTime: number = Date.now(); + + takeToken(): boolean { + this.refill(); + if (this.tokens > 0) { + this.tokens--; + return true; + } + return false; + } + + private refill(): void { + const now = Date.now(); + const timePassed = now - this.lastRefillTime; + const tokensToAdd = timePassed / this.refillRate; + + if (tokensToAdd >= 1) { + this.tokens = Math.min(this.maxTokens, this.tokens + Math.floor(tokensToAdd)); + this.lastRefillTime = now; + } + } +} + +// Global cache and rate limiter +const nominatimCache = new LRUCache(5 * 60 * 1000, 100); // 5 min TTL +const tokenBucket = new TokenBucket(); + +const NOMINATIM_BASE_URL = 'https://nominatim.openstreetmap.org/search'; +const USER_AGENT = 'BookMyVenue/1.0 (+https://github.com/ashishshaiju/bookmyvenue)'; + +const KERALA_VIEWBOX = '74.8,12.9,77.5,7.9'; +const RESULT_LIMIT = 5; +const RAW_RESULT_LIMIT = 12; + +function normalizeQuery(query: string): string { + return query.trim().toLowerCase(); +} + +function scopeQueryToKerala(query: string): string { + return normalizeQuery(query).includes('kerala') ? query : `${query}, Kerala, India`; +} + +function parseNominatimResult(item: NominatimResponse): GeoSearchResult { + const { address } = item; + const city = address.city ?? address.town ?? address.village; + const district = address.state_district ?? address.county; + const postcode = address.postcode; + + return { + displayName: item.display_name, + lat: parseFloat(item.lat), + lng: parseFloat(item.lon), + ...(city && { city }), + ...(district && { district }), + ...(postcode && { postcode }), + boundingbox: [ + parseFloat(item.boundingbox[0]), + parseFloat(item.boundingbox[1]), + parseFloat(item.boundingbox[2]), + parseFloat(item.boundingbox[3]), + ], + }; +} + +export async function searchPlace(query: string): Promise { + const normalizedQuery = normalizeQuery(query); + + // Check cache first + const cached = nominatimCache.get(normalizedQuery); + if (cached) { + return cached; + } + + // Rate limit + if (!tokenBucket.takeToken()) { + logError('Nominatim rate limit reached', { module: 'geo.service.ts/searchPlace' }); + throw new Error('Search temporarily unavailable — please try again'); + } + + try { + const url = new URL(NOMINATIM_BASE_URL); + url.searchParams.set('q', scopeQueryToKerala(query)); + url.searchParams.set('format', 'jsonv2'); + url.searchParams.set('countrycodes', 'in'); + url.searchParams.set('addressdetails', '1'); + url.searchParams.set('limit', String(RAW_RESULT_LIMIT)); + url.searchParams.set('viewbox', KERALA_VIEWBOX); + url.searchParams.set('bounded', '1'); + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'User-Agent': USER_AGENT, + }, + }); + + if (!response.ok) { + throw new Error(`Nominatim returned ${String(response.status)}`); + } + + const data = (await response.json()) as NominatimResponse[]; + const results = data + .filter((item) => (item.address.state ?? '').toLowerCase() === 'kerala') + .slice(0, RESULT_LIMIT) + .map(parseNominatimResult); + + // Cache the results + nominatimCache.set(normalizedQuery, results); + + return results; + } catch (err) { + const error = err as Error; + logError('Nominatim search failed', { + module: 'geo.service.ts/searchPlace', + error: error.message, + query, + }); + throw new Error('Search temporarily unavailable — please try again', { cause: err }); + } +} diff --git a/server/src/modules/geo/geo.types.ts b/server/src/modules/geo/geo.types.ts new file mode 100644 index 0000000000..bd2b7dc433 --- /dev/null +++ b/server/src/modules/geo/geo.types.ts @@ -0,0 +1,27 @@ +export interface GeoSearchResult { + displayName: string; + lat: number; + lng: number; + city?: string; + district?: string; + postcode?: string; + boundingbox?: [number, number, number, number]; // [south, north, west, east] +} + +export interface NominatimAddress { + city?: string; + town?: string; + village?: string; + state_district?: string; + county?: string; + state?: string; + postcode?: string; +} + +export interface NominatimResponse { + display_name: string; + lat: string; + lon: string; + address: NominatimAddress; + boundingbox: [string, string, string, string]; +} diff --git a/server/src/modules/moderation/bannedUser.controller.ts b/server/src/modules/moderation/bannedUser.controller.ts new file mode 100644 index 0000000000..7b26160dc3 --- /dev/null +++ b/server/src/modules/moderation/bannedUser.controller.ts @@ -0,0 +1,71 @@ +import type { Request, Response } from 'express'; +import { ResponseUtil } from '../../utils/responseUtils'; +import { handleError } from '../../utils/errors'; +import * as service from '../moderation/bannedUser.service'; +import type { CreateBanRequest } from './bannedUser.types'; + +export const banUser = async (req: Request, res: Response): Promise => { + try { + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const { userId, scope, reason, venueId, expiresAt } = req.body as CreateBanRequest; + + const ban = await service.banUser(adminId, userId, scope, reason, { + venueId: venueId ?? null, + expiresAt: expiresAt ? new Date(expiresAt) : null, + }); + + ResponseUtil.created(res, 'User banned successfully', ban); + } catch (e) { + handleError(res, e, 'banUser'); + } +}; + +export const liftBan = async (req: Request, res: Response): Promise => { + try { + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const banId = Array.isArray(req.params.banId) ? req.params.banId[0] : req.params.banId; + + const ban = await service.liftBan(adminId, banId); + ResponseUtil.success(res, 'Ban lifted successfully', ban); + } catch (e) { + handleError(res, e, 'liftBan'); + } +}; + +export const liftAllBansForUser = async (req: Request, res: Response): Promise => { + try { + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const userId = Array.isArray(req.params.userId) ? req.params.userId[0] : req.params.userId; + + const count = await service.liftAllBansForUser(adminId, userId); + ResponseUtil.success(res, 'All active bans lifted successfully', { liftedCount: count }); + } catch (e) { + handleError(res, e, 'liftAllBansForUser'); + } +}; + +export const getUserBans = async (req: Request, res: Response): Promise => { + try { + const userId = Array.isArray(req.params.userId) ? req.params.userId[0] : req.params.userId; + + const bans = await service.getUserBanHistory(userId); + ResponseUtil.success(res, 'User ban history retrieved', { bans }); + } catch (e) { + handleError(res, e, 'getUserBans'); + } +}; diff --git a/server/src/modules/moderation/bannedUser.model.ts b/server/src/modules/moderation/bannedUser.model.ts new file mode 100644 index 0000000000..0ef3ba5627 --- /dev/null +++ b/server/src/modules/moderation/bannedUser.model.ts @@ -0,0 +1,88 @@ +import mongoose, { type Document, Schema } from 'mongoose'; + +export type BanScope = 'full' | 'commenting' | 'owner_dashboard' | 'venue_creation'; +export type BanRecordStatus = 'active' | 'lifted' | 'expired'; + +export interface IBannedUser extends Document { + userId: mongoose.Types.ObjectId; + scope: BanScope; + venueId?: mongoose.Types.ObjectId | null; + reason: string; + bannedBy: mongoose.Types.ObjectId; + bannedAt: Date; + expiresAt?: Date | null; + status: BanRecordStatus; + liftedBy?: mongoose.Types.ObjectId | null; + liftedAt?: Date | null; + createdAt: Date; + updatedAt: Date; +} + +const BannedUserSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: 'Users', + required: true, + index: true, + }, + scope: { + type: String, + enum: ['full', 'commenting', 'owner_dashboard', 'venue_creation'], + required: true, + }, + venueId: { + type: Schema.Types.ObjectId, + ref: 'Venues', + default: null, + }, + reason: { + type: String, + required: true, + maxlength: 500, + trim: true, + }, + bannedBy: { + type: Schema.Types.ObjectId, + ref: 'Users', + required: true, + }, + bannedAt: { + type: Date, + required: true, + default: (): Date => new Date(), + }, + expiresAt: { + type: Date, + default: null, + }, + status: { + type: String, + enum: ['active', 'lifted', 'expired'], + default: 'active', + index: true, + }, + liftedBy: { + type: Schema.Types.ObjectId, + ref: 'Users', + default: null, + }, + liftedAt: { + type: Date, + default: null, + }, + }, + { timestamps: true } +); + +// Composite index for enforcement lookups +BannedUserSchema.index({ userId: 1, scope: 1, status: 1 }, { name: 'idx_user_scope_status' }); + +// Index for expiry sweep +BannedUserSchema.index({ status: 1, expiresAt: 1 }, { name: 'idx_active_expiry' }); + +export const BannedUserModel = mongoose.model( + 'BannedUsers', + BannedUserSchema, + 'BannedUsers' +); diff --git a/server/src/modules/moderation/bannedUser.repository.ts b/server/src/modules/moderation/bannedUser.repository.ts new file mode 100644 index 0000000000..fc8969353f --- /dev/null +++ b/server/src/modules/moderation/bannedUser.repository.ts @@ -0,0 +1,156 @@ +import { BannedUserModel, type IBannedUser, type BanScope } from './bannedUser.model'; +import { UserModel } from '../user/user.models'; +import mongoose from 'mongoose'; + +const toObjectId = (id: string): mongoose.Types.ObjectId => { + return new mongoose.Types.ObjectId(id); +}; + +export async function createBan( + userId: string, + scope: BanScope, + reason: string, + bannedBy: string, + options?: { venueId?: string | null; expiresAt?: Date | null } +): Promise { + const banDoc = new BannedUserModel({ + userId: toObjectId(userId), + scope, + reason, + bannedBy: toObjectId(bannedBy), + venueId: options?.venueId ? toObjectId(options.venueId) : null, + expiresAt: options?.expiresAt ?? null, + status: 'active', + bannedAt: new Date(), + }); + + await banDoc.save(); + + // For full bans, also update User.isBanned + if (scope === 'full') { + await UserModel.findByIdAndUpdate(toObjectId(userId), { + isBanned: true, + active: false, + }).exec(); + } + + return banDoc; +} + +export async function liftAllBansForUser(userId: string, liftedBy: string): Promise { + const result = await BannedUserModel.updateMany( + { userId: toObjectId(userId), status: 'active' }, + { + $set: { + status: 'lifted', + liftedBy: toObjectId(liftedBy), + liftedAt: new Date(), + }, + } + ).exec(); + + // Restore user to active/unbanned since all bans are lifted + await UserModel.findByIdAndUpdate(toObjectId(userId), { + isBanned: false, + active: true, + }).exec(); + + return result.modifiedCount; +} + +export async function liftBan(banRecordId: string, liftedBy: string): Promise { + const updated = await BannedUserModel.findByIdAndUpdate( + toObjectId(banRecordId), + { + status: 'lifted', + liftedBy: toObjectId(liftedBy), + liftedAt: new Date(), + }, + { new: true } + ).exec(); + + if (!updated) return null; + + // If lifting a full ban, check if user has any other active full bans + if (updated.scope === 'full') { + const otherFullBans = await BannedUserModel.findOne({ + userId: updated.userId, + scope: 'full', + status: 'active', + _id: { $ne: toObjectId(banRecordId) }, + }).exec(); + + // Only restore User.isBanned/active if no other active full bans exist + if (!otherFullBans) { + await UserModel.findByIdAndUpdate(updated.userId, { + isBanned: false, + active: true, + }).exec(); + } + } + + return updated; +} + +export async function findActiveBan( + userId: string, + scope: BanScope, + venueId?: string | null +): Promise { + const query: Record = { + userId: toObjectId(userId), + scope, + status: 'active', + }; + + // For venue-scoped bans (commenting, owner_dashboard), match both global and venue-specific + if (scope === 'commenting' || scope === 'owner_dashboard') { + if (venueId) { + // Match either: venueId is null (global) OR venueId matches + query.$or = [{ venueId: null }, { venueId: toObjectId(venueId) }]; + } else { + // No specific venue passed — just match global (venueId: null) + query.venueId = null; + } + } else { + // For 'full' and 'venue_creation', always global (venueId must be null/absent) + query.venueId = null; + } + + return BannedUserModel.findOne(query).exec(); +} + +export async function findUserBans(userId: string): Promise { + return BannedUserModel.find({ userId: toObjectId(userId) }) + .sort({ bannedAt: -1 }) + .populate('bannedBy', 'username email') + .populate('liftedBy', 'username email') + .lean() + .exec(); +} + +export async function findActiveFullBans(): Promise { + return BannedUserModel.find({ + scope: 'full', + status: 'active', + expiresAt: { $lte: new Date() }, + }).exec(); +} + +export async function markAsExpired(banRecordId: string): Promise { + const updated = await BannedUserModel.findByIdAndUpdate( + toObjectId(banRecordId), + { status: 'expired' }, + { new: true } + ).exec(); + + return updated; +} + +export async function findExpiredFullBans(): Promise { + return BannedUserModel.find({ + scope: 'full', + status: 'active', + expiresAt: { $ne: null, $lte: new Date() }, + }).exec(); +} diff --git a/server/src/modules/moderation/bannedUser.router.ts b/server/src/modules/moderation/bannedUser.router.ts new file mode 100644 index 0000000000..f4715f5f20 --- /dev/null +++ b/server/src/modules/moderation/bannedUser.router.ts @@ -0,0 +1,137 @@ +import { Router } from 'express'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { requireRole } from '../../middlewares/rbac.middleware'; +import { validateBody, validateParams } from '../../middlewares/validation.middleware'; +import * as controller from './bannedUser.controller'; +import * as validator from './bannedUser.validator'; +import { z } from 'zod'; + +const router: Router = Router(); + +const banIdSchema = z.object({ + banId: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid ban ID'), +}); + +const userIdSchema = z.object({ + userId: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid user ID'), +}); + +/** + * @openapi + * /moderation/bans: + * post: + * tags: [Moderation] + * summary: Create a ban + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [userId, scope, reason] + * properties: + * userId: + * type: string + * scope: + * type: string + * enum: [full, commenting, owner_dashboard, venue_creation] + * reason: + * type: string + * venueId: + * type: string + * expiresAt: + * type: string + * format: date-time + * responses: + * 201: + * description: Ban created successfully + */ +router.post( + '/', + verifyAccessToken, + requireRole('admin'), + validateBody(validator.createBanSchema), + controller.banUser +); + +/** + * @openapi + * /moderation/bans/{banId}: + * delete: + * tags: [Moderation] + * summary: Lift a ban + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: banId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Ban lifted successfully + */ +router.delete( + '/:banId', + verifyAccessToken, + requireRole('admin'), + validateParams(banIdSchema), + controller.liftBan +); + +/** + * @openapi + * /moderation/bans/user/{userId}: + * get: + * tags: [Moderation] + * summary: Get user ban history + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: User ban history + */ +router.get( + '/user/:userId', + verifyAccessToken, + requireRole('admin'), + validateParams(userIdSchema), + controller.getUserBans +); + +/** + * @openapi + * /moderation/bans/user/{userId}/lift-all: + * post: + * tags: [Moderation] + * summary: Lift all active bans for a user + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: All active bans lifted successfully + */ +router.post( + '/user/:userId/lift-all', + verifyAccessToken, + requireRole('admin'), + validateParams(userIdSchema), + controller.liftAllBansForUser +); + +export default router; diff --git a/server/src/modules/moderation/bannedUser.service.ts b/server/src/modules/moderation/bannedUser.service.ts new file mode 100644 index 0000000000..9782d7ac53 --- /dev/null +++ b/server/src/modules/moderation/bannedUser.service.ts @@ -0,0 +1,192 @@ +import * as repo from './bannedUser.repository'; +import { getUserRole } from '../../services/roles.service'; +import { ForbiddenError, NotFoundError, ValidationError } from '../../utils/errors'; +import type { IBannedUser, BanScope } from './bannedUser.model'; +import { UserModel } from '../user/user.models'; +import { VenueModel } from '../venue/venue.model'; +import { enqueueEmailTask } from '../../services/email.repository'; +import { EmailIntent, EmailTaskStatus } from '../../constants/email.constants'; +import { logWarn } from '../../utils/logger'; + +export async function banUser( + adminId: string, + userId: string, + scope: BanScope, + reason: string, + options?: { venueId?: string | null; expiresAt?: Date | null } +): Promise { + // Guard: no self-ban + if (adminId === userId) { + throw new ForbiddenError('Cannot ban yourself'); + } + + // Guard: user exists + const targetUser = await UserModel.findById(userId).exec(); + if (!targetUser) { + throw new NotFoundError('User not found'); + } + + // Guard: cannot ban a superAdmin (unless you're also admin checking permissions, but role check happens at route level) + const userRole = await getUserRole(userId); + if (userRole?.roleName === 'superAdmin') { + throw new ForbiddenError('Cannot ban a superAdmin'); + } + + // Validate: venueId only allowed for commenting/owner_dashboard scopes + if ((scope === 'full' || scope === 'venue_creation') && options?.venueId) { + throw new ValidationError('Scope ' + scope + ' must be platform-wide (venueId not allowed)'); + } + + // Validate: expiresAt must be in the future if provided + if (options?.expiresAt && options.expiresAt <= new Date()) { + throw new ValidationError('expiresAt must be in the future'); + } + + // Validate: reason length + if (reason.trim().length < 10) { + throw new ValidationError('Reason must be at least 10 characters'); + } + + const ban = await repo.createBan(userId, scope, reason, adminId, options); + + // Resolve optional venue name for the ban notification + const venueName = options?.venueId + ? await VenueModel.findById(options.venueId) + .select('name') + .lean() + .then((v) => v?.name) + : undefined; + + // Send email notification + try { + await enqueueEmailTask( + targetUser.email, + EmailIntent.USER_BANNED, + 'Important: Your Account Access Has Been Restricted', + EmailTaskStatus.PENDING, + { + scope, + reason, + ...(options?.expiresAt ? { expiresAt: options.expiresAt.toISOString() } : {}), + ...(venueName ? { venueName } : {}), + } + ); + } catch (err) { + logWarn('Failed to queue user banned email', { + module: 'bannedUser.service.ts/banUser', + userId, + error: (err as Error).message, + }); + } + + // Log activity + const { logModerationAction } = await import('./moderationActivity.service.js'); + await logModerationAction(adminId, 'ban_user', userId, 'user', reason, { scope, ...options }); + + return ban; +} + +export async function liftBan(adminId: string, banRecordId: string): Promise { + const updated = await repo.liftBan(banRecordId, adminId); + if (!updated) { + throw new NotFoundError('Ban record not found'); + } + + // Send email notification + const user = await UserModel.findById(updated.userId).select('email').lean(); + if (user?.email) { + try { + await enqueueEmailTask( + user.email, + EmailIntent.USER_UNBANNED, + 'Your Account Access Has Been Restored', + EmailTaskStatus.PENDING, + {} + ); + } catch (err) { + logWarn('Failed to queue user unbanned email', { + module: 'bannedUser.service.ts/liftBan', + banRecordId, + error: (err as Error).message, + }); + } + } + + // Log activity + const { logModerationAction } = await import('./moderationActivity.service.js'); + await logModerationAction(adminId, 'unban_user', updated.userId.toString(), 'user', undefined, { + banRecordId, + }); + + return updated; +} + +export async function liftAllBansForUser(adminId: string, userId: string): Promise { + const targetUser = await UserModel.findById(userId).exec(); + if (!targetUser) { + throw new NotFoundError('User not found'); + } + + const count = await repo.liftAllBansForUser(userId, adminId); + + if (count > 0) { + // Send email notification + try { + await enqueueEmailTask( + targetUser.email, + EmailIntent.USER_UNBANNED, + 'Your Account Access Has Been Restored', + EmailTaskStatus.PENDING, + {} + ); + } catch (err) { + logWarn('Failed to queue user unbanned email (lift all bans)', { + module: 'bannedUser.service.ts/liftAllBansForUser', + userId, + error: (err as Error).message, + }); + } + + // Log activity + const { logModerationAction } = await import('./moderationActivity.service.js'); + await logModerationAction(adminId, 'unban_user', userId, 'user', undefined, { count }); + } + + return count; +} + +export async function isBannedForScope( + userId: string, + scope: BanScope, + venueId?: string | null +): Promise { + const ban = await repo.findActiveBan(userId, scope, venueId); + return !!ban; +} + +export async function getUserBanHistory(userId: string): Promise { + return repo.findUserBans(userId); +} + +export async function expireActiveBans(): Promise { + const expiredBans = await repo.findExpiredFullBans(); + let expiredCount = 0; + + for (const ban of expiredBans) { + const updated = await repo.markAsExpired(ban._id.toString()); + if (updated?.scope === 'full') { + // Check if user has any other active full bans + const otherFullBans = await repo.findActiveBan(ban.userId.toString(), 'full'); + if (!otherFullBans) { + // Restore User.isBanned/active + await UserModel.findByIdAndUpdate(ban.userId, { + isBanned: false, + active: true, + }).exec(); + } + } + expiredCount++; + } + + return expiredCount; +} diff --git a/server/src/modules/moderation/bannedUser.types.ts b/server/src/modules/moderation/bannedUser.types.ts new file mode 100644 index 0000000000..ab69fd6db5 --- /dev/null +++ b/server/src/modules/moderation/bannedUser.types.ts @@ -0,0 +1,9 @@ +import type { BanScope } from './bannedUser.model'; + +export interface CreateBanRequest { + userId: string; + scope: BanScope; + reason: string; + venueId?: string; + expiresAt?: string; +} diff --git a/server/src/modules/moderation/bannedUser.validator.ts b/server/src/modules/moderation/bannedUser.validator.ts new file mode 100644 index 0000000000..a86e018859 --- /dev/null +++ b/server/src/modules/moderation/bannedUser.validator.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const createBanSchema = z.object({ + userId: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid user ID'), + scope: z.enum(['full', 'commenting', 'owner_dashboard', 'venue_creation']), + reason: z.string().trim().min(10, 'Reason must be at least 10 characters').max(500), + venueId: z + .string() + .regex(/^[a-f\d]{24}$/i) + .nullable() + .optional(), + expiresAt: z.string().pipe(z.coerce.date()).nullable().optional(), +}); + +export type CreateBanDTO = z.infer; diff --git a/server/src/modules/moderation/moderation.repository.ts b/server/src/modules/moderation/moderation.repository.ts new file mode 100644 index 0000000000..c9c540c801 --- /dev/null +++ b/server/src/modules/moderation/moderation.repository.ts @@ -0,0 +1,61 @@ +import { ReviewModel } from '../review/review.model'; +import { VenueModel } from '../venue/venue.model'; +import { BannedUserModel } from './bannedUser.model'; +import type { + FlaggedReviewLean, + HideRequestLean, + SuspendedVenueLean, + BannedUserLean, +} from './moderation.types'; + +export async function getTopFlaggedReviews(limit = 10): Promise { + return ReviewModel.find({ + status: 'flagged', + }) + .sort({ moderatedAt: -1, createdAt: -1 }) + .limit(limit) + .populate('userId', 'username') + .populate('venueId', 'name') + .lean() + .exec() as unknown as Promise; +} + +export async function getTopHideRequests(limit = 10): Promise { + return ReviewModel.find({ + hideRequestStatus: 'pending', + }) + .sort({ hideRequestedAt: -1 }) + .limit(limit) + .populate('userId', 'username email') + .populate({ + path: 'venueId', + select: 'name ownerUserId', + populate: { path: 'ownerUserId', select: 'username email' }, + }) + .lean() + .exec() as unknown as Promise; +} + +export async function getTopSuspendedVenues(limit = 10): Promise { + return VenueModel.find({ + status: 'Suspended', + deleted: false, + }) + .sort({ updatedAt: -1 }) + .limit(limit) + .select('_id name suspensionReason ownerUserId createdAt updatedAt') + .lean() + .exec(); +} + +export async function getTopBannedUsers(limit = 10): Promise { + return BannedUserModel.find({ + status: 'active', + }) + .sort({ bannedAt: -1 }) + .limit(limit) + .populate('userId', 'username email') + .populate('bannedBy', 'username email') + .lean() + .exec() as unknown as Promise; +} diff --git a/server/src/modules/moderation/moderation.router.ts b/server/src/modules/moderation/moderation.router.ts new file mode 100644 index 0000000000..929fc5e927 --- /dev/null +++ b/server/src/modules/moderation/moderation.router.ts @@ -0,0 +1,93 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { requireRole } from '../../middlewares/rbac.middleware'; +import { ResponseUtil } from '../../utils/responseUtils'; +import { handleError } from '../../utils/errors'; +import { getModerationSummary } from './moderation.service'; +import { getModerationLogs } from './moderationActivity.service'; +import bannedUserRouter from './bannedUser.router'; + +const router: Router = Router(); + +/** + * @openapi + * /moderation/summary: + * get: + * tags: [Moderation] + * summary: Get moderation dashboard summary (admin only) + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Moderation summary with flagged reviews, suspended venues, banned users + * 401: + * description: Not authenticated + * 403: + * description: Not admin + */ +router + .route('/summary') + .get( + verifyAccessToken, + requireRole('admin'), + async (_req: Request, res: Response): Promise => { + try { + const summary = await getModerationSummary(); + ResponseUtil.success(res, 'Moderation summary retrieved', summary); + } catch (err) { + handleError(res, err, 'getModerationSummary'); + } + } + ); + +/** + * @openapi + * /moderation/logs: + * get: + * tags: [Moderation] + * summary: Get moderation logs (superAdmin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * description: Page number for pagination + * - in: query + * name: limit + * schema: + * type: integer + * description: Number of items per page + * responses: + * 200: + * description: List of moderation logs with pagination info + * 401: + * description: Not authenticated + * 403: + * description: Not authorized (requires superAdmin) + */ +router + .route('/logs') + .get( + verifyAccessToken, + requireRole('superAdmin'), + async (req: Request, res: Response): Promise => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const { logs, total } = await getModerationLogs(page, limit); + ResponseUtil.success(res, 'Moderation logs retrieved', { + logs, + pagination: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }); + } catch (err) { + handleError(res, err, 'getModerationLogs'); + } + } + ); + +router.use('/bans', bannedUserRouter); + +export { router as moderationRouter }; diff --git a/server/src/modules/moderation/moderation.service.ts b/server/src/modules/moderation/moderation.service.ts new file mode 100644 index 0000000000..0279f0c667 --- /dev/null +++ b/server/src/modules/moderation/moderation.service.ts @@ -0,0 +1,68 @@ +import type { ModerationSummary } from './moderation.types'; +import * as repo from './moderation.repository'; + +export async function getModerationSummary(): Promise { + // Get top 10 flagged/removed reviews + const flaggedReviews = await repo.getTopFlaggedReviews(10); + + // Get top 10 pending hide requests + const hideRequests = await repo.getTopHideRequests(10); + + // Get top 10 suspended venues + const suspendedVenues = await repo.getTopSuspendedVenues(10); + + // Get top 10 banned users + const bannedUsers = await repo.getTopBannedUsers(10); + + return { + flaggedReviews: flaggedReviews.map((r) => ({ + _id: r._id.toString(), + rating: r.rating, + comment: r.comment, + venueId: r.venueId._id.toString(), + venueName: r.venueId.name, + userId: r.userId._id.toString(), + userName: r.userId.username, + moderationReason: r.moderationReason, + moderatedAt: r.moderatedAt, + createdAt: r.createdAt, + })), + hideRequests: hideRequests.map((r) => ({ + _id: r._id.toString(), + rating: r.rating, + comment: r.comment, + venueId: r.venueId._id.toString(), + venueName: r.venueId.name, + ownerId: r.venueId.ownerUserId._id.toString(), + ownerUsername: r.venueId.ownerUserId.username, + ownerEmail: r.venueId.ownerUserId.email, + userId: r.userId._id.toString(), + userName: r.userId.username, + userEmail: r.userId.email, + hideRequestReason: r.hideRequestReason, + hideRequestedAt: r.hideRequestedAt, + createdAt: r.createdAt, + })), + suspendedVenues: suspendedVenues.map((v) => ({ + _id: v._id.toString(), + name: v.name, + suspensionReason: v.suspensionReason, + ownerUserId: v.ownerUserId.toString(), + suspendedAt: v.updatedAt ?? v.createdAt, + createdAt: v.createdAt, + })), + bannedUsers: bannedUsers.map((u) => ({ + _id: u._id.toString(), + userId: u.userId._id.toString(), + username: u.userId.username, + email: u.userId.email, + scope: u.scope, + banReason: u.reason, + bannedBy: u.bannedBy?.username ?? u.bannedBy?._id.toString(), + bannedAt: u.bannedAt, + expiresAt: u.expiresAt, + venueId: u.venueId?.toString(), + createdAt: u.createdAt, + })), + }; +} diff --git a/server/src/modules/moderation/moderation.types.ts b/server/src/modules/moderation/moderation.types.ts new file mode 100644 index 0000000000..347f8cb4c2 --- /dev/null +++ b/server/src/modules/moderation/moderation.types.ts @@ -0,0 +1,100 @@ +import type { Types } from 'mongoose'; + +export interface FlaggedReviewLean { + _id: Types.ObjectId; + rating: number; + comment?: string; + venueId: { _id: Types.ObjectId; name: string }; + userId: { _id: Types.ObjectId; username: string }; + moderationReason?: string; + moderatedAt?: Date; + createdAt: Date; +} + +export interface HideRequestLean { + _id: Types.ObjectId; + rating: number; + comment?: string; + venueId: { + _id: Types.ObjectId; + name: string; + ownerUserId: { _id: Types.ObjectId; username: string; email: string }; + }; + userId: { _id: Types.ObjectId; username: string; email: string }; + hideRequestReason?: string; + hideRequestedAt?: Date; + createdAt: Date; +} + +export interface SuspendedVenueLean { + _id: Types.ObjectId; + name: string; + suspensionReason?: string; + ownerUserId: Types.ObjectId; + createdAt: Date; + updatedAt?: Date; +} + +export interface BannedUserLean { + _id: Types.ObjectId; + userId: { _id: Types.ObjectId; username: string; email: string }; + scope: string; + reason: string; + bannedAt: Date; + expiresAt?: Date | null; + bannedBy?: { _id: Types.ObjectId; username: string; email: string }; + venueId?: Types.ObjectId | null; + createdAt: Date; +} + +export interface ModerationSummary { + flaggedReviews: { + _id: string; + rating: number; + comment?: string; + venueId: string; + venueName?: string; + userId: string; + userName?: string; + moderationReason?: string; + moderatedAt?: Date; + createdAt: Date; + }[]; + hideRequests: { + _id: string; + rating: number; + comment?: string; + venueId: string; + venueName?: string; + ownerId: string; + ownerUsername?: string; + ownerEmail?: string; + userId: string; + userName?: string; + userEmail?: string; + hideRequestReason?: string; + hideRequestedAt?: Date; + createdAt: Date; + }[]; + suspendedVenues: { + _id: string; + name: string; + suspensionReason?: string; + ownerUserId: string; + suspendedAt?: Date; + createdAt: Date; + }[]; + bannedUsers: { + _id: string; + userId: string; + username: string; + email: string; + scope: string; + banReason: string; + bannedBy?: string; + bannedAt: Date; + expiresAt?: Date | null; + venueId?: string | null; + createdAt: Date; + }[]; +} diff --git a/server/src/modules/moderation/moderationActivity.model.ts b/server/src/modules/moderation/moderationActivity.model.ts new file mode 100644 index 0000000000..3e4a605c55 --- /dev/null +++ b/server/src/modules/moderation/moderationActivity.model.ts @@ -0,0 +1,58 @@ +import type { Document, Types } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +export type ModerationActionType = + | 'ban_user' + | 'unban_user' + | 'suspend_venue' + | 'unsuspend_venue' + | 'remove_review' + | 'restore_review' + | 'auto_suspend_venue' + | 'extend_venue_deadline' + | 'reject_venue'; + +export interface IModerationActivity extends Document { + adminId: mongoose.Types.ObjectId; + action: ModerationActionType; + targetId: string; // The ID of the user, venue, or review + targetType: 'user' | 'venue' | 'review'; + reason?: string; + metadata?: Record; + createdAt: Date; +} + +const ModerationActivitySchema = new Schema( + { + adminId: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + action: { type: String, required: true }, + targetId: { type: String, required: true }, + targetType: { type: String, enum: ['user', 'venue', 'review'], required: true }, + reason: { type: String }, + metadata: { type: Schema.Types.Mixed }, + }, + { timestamps: { createdAt: true, updatedAt: false } } +); + +// Index for faster queries on logs +ModerationActivitySchema.index({ adminId: 1, createdAt: -1 }); +ModerationActivitySchema.index({ createdAt: -1 }); +ModerationActivitySchema.index({ action: 1 }); +ModerationActivitySchema.index({ targetId: 1, targetType: 1 }); + +export const ModerationActivityModel = mongoose.model( + 'ModerationActivity', + ModerationActivitySchema, + 'ModerationActivities' +); + +export interface ModerationLogLean { + _id: Types.ObjectId; + adminId: { _id: Types.ObjectId; username: string; email: string }; + action: ModerationActionType; + targetId: string; + targetType: 'user' | 'venue' | 'review'; + reason?: string; + metadata?: Record; + createdAt: Date; +} diff --git a/server/src/modules/moderation/moderationActivity.service.ts b/server/src/modules/moderation/moderationActivity.service.ts new file mode 100644 index 0000000000..bf25b49c31 --- /dev/null +++ b/server/src/modules/moderation/moderationActivity.service.ts @@ -0,0 +1,53 @@ +import { + ModerationActivityModel, + type ModerationActionType, + type ModerationLogLean, +} from './moderationActivity.model'; +import type mongoose from 'mongoose'; +import { logError } from '../../utils/logger'; + +export async function logModerationAction( + adminId: string | mongoose.Types.ObjectId, + action: ModerationActionType, + targetId: string, + targetType: 'user' | 'venue' | 'review', + reason?: string, + metadata?: Record +): Promise { + try { + await ModerationActivityModel.create({ + adminId, + action, + targetId, + targetType, + reason, + metadata, + }); + } catch (error) { + // Log the error but don't fail the primary moderation action + logError('Failed to log moderation activity', { + module: 'moderationActivity.service', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export async function getModerationLogs( + page: number, + limit: number +): Promise<{ logs: ModerationLogLean[]; total: number }> { + const skip = (page - 1) * limit; + + const [logs, total] = await Promise.all([ + ModerationActivityModel.find() + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .populate('adminId', 'username email') + .lean() + .exec() as unknown as Promise, + ModerationActivityModel.countDocuments(), + ]); + + return { logs, total }; +} diff --git a/server/src/modules/owner/owner.controller.ts b/server/src/modules/owner/owner.controller.ts new file mode 100644 index 0000000000..fd1f68f16d --- /dev/null +++ b/server/src/modules/owner/owner.controller.ts @@ -0,0 +1,291 @@ +import type { z } from 'zod'; +import type { Request, Response } from 'express'; +import { ResponseUtil } from '../../utils/responseUtils'; +import { handleError } from '../../utils/errors'; +import type { + blockDatesSchema, + unblockDatesSchema, + offlineBookingSchema, + ownerReplySchema, + reportReviewSchema, + inactivityRequestSchema, + deleteRequestSchema, +} from './owner.validator'; +import * as service from './owner.service'; +import * as workflow from './owner.workflow'; +import * as reviewService from '../review/review.service'; + +// GET /api/v1/owner/analytics/:venueId +export const getVenueAnalytics = async (req: Request, res: Response): Promise => { + try { + const venueId = req.params.venueId as string; + const result = await service.getVenueAnalyticsService(venueId); + ResponseUtil.success(res, 'Analytics retrieved successfully', result); + } catch (e) { + handleError(res, e, 'getVenueAnalytics'); + } +}; + +// GET /api/v1/owner/venue/:venueId/bookings +export const getVenueBookings = async (req: Request, res: Response): Promise => { + try { + const venueId = req.params.venueId as string; + const { page, limit } = req.pagination ?? { page: 1, limit: 10, skip: 0, sort: '' }; + + const result = await service.getVenueBookingsService(venueId, page, limit); + + ResponseUtil.success(res, 'Venue bookings retrieved', result); + } catch (e) { + handleError(res, e, 'getVenueBookings'); + } +}; + +// POST /api/v1/owner/bookings/offline +export const createOfflineBooking = async (req: Request, res: Response): Promise => { + try { + if (!req.user?.userId) { + ResponseUtil.unauthorized(res, 'User not authenticated'); + return; + } + const dto = req.validated?.body as z.infer; + + const result = await service.createOfflineBookingService(req.user.userId, dto); + + ResponseUtil.created(res, 'Offline booking created', result); + } catch (e) { + handleError(res, e, 'createOfflineBooking'); + } +}; + +// GET /api/v1/owner/venue/:venueId/availability-calendar +export const getVenueAvailabilityCalendar = async (req: Request, res: Response): Promise => { + try { + const venueId = req.params.venueId as string; + + const result = await workflow.getVenueAvailabilityCalendarWorkflow(venueId); + if (!result) { + ResponseUtil.notFound(res, 'Venue not found'); + return; + } + + ResponseUtil.success(res, 'Availability calendar retrieved', result); + } catch (e) { + handleError(res, e, 'getVenueAvailabilityCalendar'); + } +}; + +// POST /api/v1/owner/:venueId/block-dates +export const blockDates = async (req: Request, res: Response): Promise => { + try { + const venueId = req.params.venueId as string; + const { dates } = req.validated?.body as z.infer; + + const blockedDates = await service.blockDatesService(venueId, dates); + + ResponseUtil.success(res, 'Dates blocked successfully', blockedDates); + } catch (e) { + handleError(res, e, 'blockDates'); + } +}; + +// POST /api/v1/owner/:venueId/unblock-dates +export const unblockDates = async (req: Request, res: Response): Promise => { + try { + const venueId = req.params.venueId as string; + const { dates } = req.validated?.body as z.infer; + + const blockedDates = await service.unblockDatesService(venueId, dates); + + ResponseUtil.success(res, 'Dates unblocked successfully', blockedDates); + } catch (e) { + handleError(res, e, 'unblockDates'); + } +}; + +// GET /api/v1/owner/venue/:venueId/reviews +export const getVenueReviews = async (req: Request, res: Response): Promise => { + try { + const venueId = req.params.venueId as string; + const { page, limit, skip } = req.pagination ?? { page: 1, limit: 10, skip: 0, sort: '' }; + + const result = await reviewService.getOwnerVenueReviews(venueId, { + page, + limit, + skip, + sort: '', + }); + ResponseUtil.paginated( + res, + 'Venue reviews retrieved', + result.reviews, + result.pagination, + 'reviews' + ); + } catch (e) { + handleError(res, e, 'getVenueReviews'); + } +}; + +// POST /api/v1/owner/venue/:venueId/reviews/:reviewId/reply +export const replyToReview = async (req: Request, res: Response): Promise => { + try { + const venueId = req.params.venueId as string; + const reviewId = req.params.reviewId as string; + const { text } = req.validated?.body as z.infer; + + const review = await reviewService.replyToReview(venueId, reviewId, text); + ResponseUtil.success(res, 'Reply added successfully', review); + } catch (e) { + handleError(res, e, 'replyToReview'); + } +}; + +// POST /api/v1/owner/venue/:venueId/reviews/:reviewId/report +export const reportReview = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const venueId = req.params.venueId as string; + const reviewId = req.params.reviewId as string; + const { reason, action } = req.validated?.body as z.infer; + + let review; + if (action === 'hide') { + review = await reviewService.requestHideForReview(venueId, reviewId, userId, reason); + } else { + review = await reviewService.flagReview(reviewId, reason); + } + ResponseUtil.success(res, 'Report submitted successfully', review); + } catch (e) { + handleError(res, e, 'reportReview'); + } +}; + +// GET /api/v1/owner/venue/:venueId/settings +export const getVenueSettings = async (req: Request, res: Response): Promise => { + try { + const venueId = req.params.venueId as string; + const result = await service.getVenueSettingsService(venueId); + ResponseUtil.success(res, 'Venue settings retrieved successfully', result); + } catch (e) { + handleError(res, e, 'getVenueSettings'); + } +}; + +// POST /api/v1/owner/venue/:venueId/request-inactivity +export const requestInactivity = async (req: Request, res: Response): Promise => { + try { + if (!req.user?.userId) { + ResponseUtil.unauthorized(res, 'User not authenticated'); + return; + } + const venueId = req.params.venueId as string; + const { reason } = req.validated?.body as z.infer; + const result = await service.requestInactivityService(venueId, req.user.userId, reason); + ResponseUtil.success(res, 'Inactivity request submitted', result); + } catch (e) { + handleError(res, e, 'requestInactivity'); + } +}; + +// DELETE /api/v1/owner/venue/:venueId/request-inactivity +export const withdrawInactivity = async (req: Request, res: Response): Promise => { + try { + if (!req.user?.userId) { + ResponseUtil.unauthorized(res, 'User not authenticated'); + return; + } + const venueId = req.params.venueId as string; + const result = await service.withdrawInactivityService(venueId, req.user.userId); + ResponseUtil.success(res, 'Inactivity request withdrawn', result); + } catch (e) { + handleError(res, e, 'withdrawInactivity'); + } +}; + +// POST /api/v1/owner/venue/:venueId/block-bookings +export const blockBookings = async (req: Request, res: Response): Promise => { + try { + if (!req.user?.userId) { + ResponseUtil.unauthorized(res, 'User not authenticated'); + return; + } + const venueId = req.params.venueId as string; + const result = await service.blockBookingsService(venueId, req.user.userId); + ResponseUtil.success(res, 'Bookings blocked successfully', result); + } catch (e) { + handleError(res, e, 'blockBookings'); + } +}; + +// DELETE /api/v1/owner/venue/:venueId/block-bookings +export const unblockBookings = async (req: Request, res: Response): Promise => { + try { + if (!req.user?.userId) { + ResponseUtil.unauthorized(res, 'User not authenticated'); + return; + } + const venueId = req.params.venueId as string; + const result = await service.unblockBookingsService(venueId, req.user.userId); + ResponseUtil.success(res, 'Booking block removed successfully', result); + } catch (e) { + handleError(res, e, 'unblockBookings'); + } +}; + +// POST /api/v1/owner/venue/:venueId/activate +export const activateVenue = async (req: Request, res: Response): Promise => { + try { + if (!req.user?.userId) { + ResponseUtil.unauthorized(res, 'User not authenticated'); + return; + } + const venueId = req.params.venueId as string; + const result = await service.activateVenueService(venueId, req.user.userId); + ResponseUtil.success(res, 'Venue reactivated successfully', result); + } catch (e) { + handleError(res, e, 'activateVenue'); + } +}; + +// PATCH /api/v1/owner/bookings/:bookingId/mark-paid +export const markBookingAsPaid = async (req: Request, res: Response): Promise => { + try { + const bookingId = req.params.bookingId as string; + await service.markBookingAsPaidService(bookingId); + ResponseUtil.success(res, 'Booking marked as paid'); + } catch (e) { + handleError(res, e, 'markBookingAsPaid'); + } +}; + +// PATCH /api/v1/owner/bookings/:bookingId/cancel-pending +export const cancelPendingOfflineBooking = async (req: Request, res: Response): Promise => { + try { + const bookingId = req.params.bookingId as string; + await service.cancelPendingOfflineBookingService(bookingId); + ResponseUtil.success(res, 'Pending offline booking cancelled'); + } catch (e) { + handleError(res, e, 'cancelPendingOfflineBooking'); + } +}; + +// POST /api/v1/owner/venue/:venueId/delete-request +export const requestDeleteVenue = async (req: Request, res: Response): Promise => { + try { + if (!req.user?.userId) { + ResponseUtil.unauthorized(res, 'User not authenticated'); + return; + } + const venueId = req.params.venueId as string; + const { reason } = req.validated?.body as z.infer; + const result = await service.requestDeleteVenueService(venueId, req.user.userId, reason); + ResponseUtil.success(res, 'Deletion request submitted', result); + } catch (e) { + handleError(res, e, 'requestDeleteVenue'); + } +}; diff --git a/server/src/modules/owner/owner.repository.ts b/server/src/modules/owner/owner.repository.ts new file mode 100644 index 0000000000..117c905735 --- /dev/null +++ b/server/src/modules/owner/owner.repository.ts @@ -0,0 +1,253 @@ +import { Types } from 'mongoose'; +import { BookingModel } from '../booking/models/booking.model'; +import { VenueModel } from '../venue/venue.model'; +import { BookingStatus } from '../../constants/booking.constants'; +import { PaymentStatus } from '../../constants/payment.constants'; +import type { IBooking, AggregatedBooking } from '../booking/booking.types'; +import type { IVenue } from '../venue/venue.types'; + +export async function getVenueAnalyticsData(venueId: string): Promise[]> { + const pipeline = [ + { + $match: { + venueId: new Types.ObjectId(venueId), + status: { $in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + }, + }, + { + $group: { + _id: { + year: { $substrCP: ['$date', 0, 4] }, + month: { $substrCP: ['$date', 5, 2] }, + }, + revenue: { $sum: '$price' }, + count: { $sum: 1 }, + }, + }, + { $sort: { '_id.year': 1, '_id.month': 1 } as Record }, + { + $project: { + _id: 0, + year: '$_id.year', + month: '$_id.month', + revenue: 1, + count: 1, + }, + }, + ]; + + return BookingModel.aggregate(pipeline); +} + +export async function getVenueBookingsPaginated( + venueId: string, + skip: number, + limit: number +): Promise[]> { + interface OwnerAggregatedBooking extends AggregatedBooking { + bookerName?: string; + bookerEmail?: string; + bookerPhone?: string; + } + const bookings = await BookingModel.aggregate([ + { $match: { venueId: new Types.ObjectId(venueId) } }, + { $sort: { date: -1, startTime: -1 } }, + { $skip: skip }, + { $limit: limit }, + { + $lookup: { + from: 'Venues', + localField: 'venueId', + foreignField: '_id', + as: 'venue', + }, + }, + { $unwind: '$venue' }, + { + $lookup: { + from: 'Users', + localField: 'userId', + foreignField: '_id', + as: 'user', + }, + }, + { $unwind: { path: '$user', preserveNullAndEmptyArrays: true } }, + { + $addFields: { + bookerEmail: '$bookerInfo.email', + bookerPhone: '$bookerInfo.phone', + bookerName: '$bookerInfo.name', + }, + }, + { + $project: { + 'venue._id': 1, + 'venue.name': 1, + 'venue.city': 1, + 'venue.address': 1, + 'user._id': 1, + 'user.username': 1, + 'user.email': 1, + 'user.phone': 1, + _id: 1, + date: 1, + startTime: 1, + endTime: 1, + price: 1, + status: 1, + paymentStatus: 1, + paymentMethod: 1, + paymentReference: 1, + bookerName: 1, + bookerEmail: 1, + bookerPhone: 1, + createdAt: 1, + eventType: 1, + userId: 1, + bookerInfo: 1, + }, + }, + ]); + + const now = new Date(); + const result: Record[] = []; + for (const b of bookings) { + let uiStatus: string; + if (b.status === BookingStatus.CANCELLED) { + uiStatus = 'cancelled'; + } else if (b.status === BookingStatus.COMPLETED) { + uiStatus = 'completed'; + } else { + const eventEnd = new Date(`${b.date}T00:00:00`); + eventEnd.setMinutes(b.endTime); + uiStatus = eventEnd < now ? 'completed' : 'confirmed'; + } + result.push({ ...b, uiStatus }); + } + return result; +} + +export async function countVenueBookings(venueId: string): Promise { + return BookingModel.countDocuments({ venueId: new Types.ObjectId(venueId) }); +} + +export async function createOfflineBookingRecord( + venueId: string, + userId: string, + date: string, + startTime: number, + endTime: number, + price: number, + customerName: string, + phone: string +): Promise { + return BookingModel.create({ + venueId: new Types.ObjectId(venueId), + userId: new Types.ObjectId(userId), + date, + startTime, + endTime, + price, + paymentReference: `OFFLINE-${Date.now().toString()}`, + status: BookingStatus.CONFIRMED, + paymentStatus: PaymentStatus.PENDING, + paymentMethod: 'offline', + bookerInfo: { + name: customerName, + phone: phone, + }, + }); +} + +export async function getVenueBlockedDatesAndWorkingDays(venueId: string): Promise { + return VenueModel.findById(venueId) + .select('blockedDates workingDays temporaryBlockAfterDate inactivity.blockedAfterDate') + .lean() + .exec(); +} + +export async function getConfirmedBookingsAfterDate( + venueId: string, + dateThreshold: string +): Promise { + return BookingModel.find({ + venueId: new Types.ObjectId(venueId), + status: { $in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + date: { $gte: dateThreshold }, + }) + .select('date') + .lean() + .exec(); +} + +export async function findConflictingBookingForDates( + venueId: string, + dates: string[] +): Promise { + return BookingModel.findOne({ + venueId: new Types.ObjectId(venueId), + status: BookingStatus.CONFIRMED, + date: { $in: dates }, + }) + .lean() + .exec(); +} + +export async function addBlockedDatesToVenue( + venueId: string, + dates: Date[] +): Promise { + return VenueModel.findByIdAndUpdate( + venueId, + { + $addToSet: { + blockedDates: { $each: dates }, + }, + }, + { new: true } + ) + .select('blockedDates') + .lean() + .exec(); +} + +export async function markBookingAsPaid(bookingId: string): Promise { + return BookingModel.findOneAndUpdate( + { + _id: new Types.ObjectId(bookingId), + paymentReference: /^OFFLINE-/, + paymentStatus: PaymentStatus.PENDING, + }, + { $set: { paymentStatus: PaymentStatus.PAID } }, + { new: true } + ).lean(); +} + +export async function cancelPendingOfflineBooking(bookingId: string): Promise { + return BookingModel.findOneAndUpdate( + { + _id: new Types.ObjectId(bookingId), + paymentStatus: PaymentStatus.PENDING, + }, + { $set: { status: BookingStatus.CANCELLED } }, + { new: true } + ).lean(); +} + +export async function removeBlockedDatesFromVenue( + venueId: string, + dates: Date[] +): Promise { + return VenueModel.findByIdAndUpdate( + venueId, + { + $pullAll: { + blockedDates: dates, + }, + }, + { new: true } + ) + .select('blockedDates') + .lean() + .exec(); +} diff --git a/server/src/modules/owner/owner.router.ts b/server/src/modules/owner/owner.router.ts new file mode 100644 index 0000000000..fa85e6c8ef --- /dev/null +++ b/server/src/modules/owner/owner.router.ts @@ -0,0 +1,701 @@ +import { Router } from 'express'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { requirePermission } from '../../middlewares/rbac.middleware'; +import { ownerTenantMiddleware } from '../../middlewares/ownerTenant.middleware'; +import { validateBody, validateParams } from '../../middlewares/validation.middleware'; +import { paginationMiddleware } from '../../middlewares/pagination.middleware'; +import { idempotencyMiddleware } from '../../middlewares/idempotency.middleware'; +import { PERMISSIONS as P } from '../../constants/permissions'; +import * as controller from './owner.controller'; +import * as validator from './owner.validator'; + +const router: Router = Router(); + +/** + * @openapi + * /owner/analytics/{venueId}: + * get: + * tags: [Owner] + * summary: Get analytics for a specific venue + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Venue analytics data + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.get( + '/analytics/:venueId', + verifyAccessToken, + requirePermission(P.venues.read), + validateParams(validator.analyticsParamsSchema), + ownerTenantMiddleware, + controller.getVenueAnalytics +); + +/** + * @openapi + * /owner/{venueId}/block-dates: + * post: + * tags: [Owner] + * summary: Block dates for a venue + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [dates] + * properties: + * dates: + * type: array + * items: + * type: string + * format: date + * example: '2025-12-25' + * description: Array of dates to block (YYYY-MM-DD, up to 6 months ahead) + * responses: + * 200: + * description: Dates blocked successfully + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.post( + '/:venueId/block-dates', + verifyAccessToken, + requirePermission(P.venues.update), + validateParams(validator.analyticsParamsSchema), + validateBody(validator.blockDatesSchema), + ownerTenantMiddleware, + controller.blockDates +); + +/** + * @openapi + * /owner/{venueId}/unblock-dates: + * post: + * tags: [Owner] + * summary: Unblock dates for a venue + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [dates] + * properties: + * dates: + * type: array + * items: + * type: string + * format: date + * example: '2025-12-25' + * description: Array of dates to unblock (YYYY-MM-DD) + * responses: + * 200: + * description: Dates unblocked successfully + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.post( + '/:venueId/unblock-dates', + verifyAccessToken, + requirePermission(P.venues.update), + validateParams(validator.analyticsParamsSchema), + validateBody(validator.unblockDatesSchema), + ownerTenantMiddleware, + controller.unblockDates +); + +/** + * @openapi + * /owner/venue/{venueId}/bookings: + * get: + * tags: [Owner] + * summary: Get all bookings for a specific venue + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: List of venue bookings + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.get( + '/venue/:venueId/bookings', + verifyAccessToken, + requirePermission(P.bookings.read), + validateParams(validator.analyticsParamsSchema), + ownerTenantMiddleware, + paginationMiddleware(), + controller.getVenueBookings +); + +/** + * @openapi + * /owner/venue/{venueId}/availability-calendar: + * get: + * tags: [Owner] + * summary: Get availability calendar for a venue + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * - in: query + * name: month + * schema: + * type: integer + * - in: query + * name: year + * schema: + * type: integer + * responses: + * 200: + * description: Availability calendar data + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.get( + '/venue/:venueId/availability-calendar', + verifyAccessToken, + requirePermission(P.venues.read), + validateParams(validator.analyticsParamsSchema), + ownerTenantMiddleware, + controller.getVenueAvailabilityCalendar +); + +/** + * @openapi + * /owner/bookings/offline: + * post: + * tags: [Owner] + * summary: Create an offline booking + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [venueId, date, startTime, endTime, customerName, customerPhone] + * properties: + * venueId: + * type: string + * date: + * type: string + * format: date + * startTime: + * type: integer + * endTime: + * type: integer + * customerName: + * type: string + * customerPhone: + * type: string + * responses: + * 200: + * description: Offline booking created successfully + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.post( + '/bookings/offline', + verifyAccessToken, + requirePermission(P.bookings.create), + validateBody(validator.offlineBookingSchema), + ownerTenantMiddleware, + controller.createOfflineBooking +); + +/** + * @openapi + * /owner/venue/{venueId}/reviews: + * get: + * tags: [Owner] + * summary: Get reviews for a specific venue (owner only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Reviews for the venue + * 403: + * description: Not the venue owner + */ +router.get( + '/venue/:venueId/reviews', + verifyAccessToken, + requirePermission(P.reviews.read), + validateParams(validator.analyticsParamsSchema), + ownerTenantMiddleware, + paginationMiddleware(), + controller.getVenueReviews +); + +/** + * @openapi + * /owner/venue/{venueId}/reviews/{reviewId}/reply: + * post: + * tags: [Owner] + * summary: Reply to a review (owner only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * - in: path + * name: reviewId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [text] + * properties: + * text: + * type: string + * maxLength: 500 + * responses: + * 200: + * description: Reply added successfully + * 403: + * description: Not the venue owner + */ +router.post( + '/venue/:venueId/reviews/:reviewId/reply', + verifyAccessToken, + requirePermission(P.reviews.update), + validateParams(validator.reviewParamsSchema), + validateBody(validator.ownerReplySchema), + ownerTenantMiddleware, + controller.replyToReview +); + +/** + * @openapi + * /owner/venue/{venueId}/reviews/{reviewId}/report: + * post: + * tags: [Owner] + * summary: Report a review to admin for hiding (owner only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * - in: path + * name: reviewId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [reason] + * properties: + * reason: + * type: string + * minLength: 10 + * maxLength: 500 + * responses: + * 200: + * description: Report submitted to admin + * 403: + * description: Not the venue owner + * 409: + * description: Hide request already pending + */ +router.post( + '/venue/:venueId/reviews/:reviewId/report', + verifyAccessToken, + requirePermission(P.reviews.update), + validateParams(validator.reviewParamsSchema), + validateBody(validator.reportReviewSchema), + ownerTenantMiddleware, + controller.reportReview +); + +/** + * @openapi + * /owner/venue/{venueId}/settings: + * get: + * tags: [Owner] + * summary: Get venue settings + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Venue settings data + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.get( + '/venue/:venueId/settings', + verifyAccessToken, + requirePermission(P.venues.read), + validateParams(validator.analyticsParamsSchema), + ownerTenantMiddleware, + controller.getVenueSettings +); + +/** + * @openapi + * /owner/venue/{venueId}/request-inactivity: + * post: + * tags: [Owner] + * summary: Request venue inactivity (requires admin approval) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * reason: + * type: string + * responses: + * 200: + * description: Inactivity request submitted + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.post( + '/venue/:venueId/request-inactivity', + verifyAccessToken, + requirePermission(P.venues.update), + validateParams(validator.analyticsParamsSchema), + validateBody(validator.inactivityRequestSchema), + ownerTenantMiddleware, + idempotencyMiddleware(), + controller.requestInactivity +); + +/** + * @openapi + * /owner/venue/{venueId}/request-inactivity: + * delete: + * tags: [Owner] + * summary: Withdraw inactivity request + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Inactivity request withdrawn + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.delete( + '/venue/:venueId/request-inactivity', + verifyAccessToken, + requirePermission(P.venues.update), + validateParams(validator.analyticsParamsSchema), + ownerTenantMiddleware, + idempotencyMiddleware(), + controller.withdrawInactivity +); + +/** + * @openapi + * /owner/venue/{venueId}/block-bookings: + * post: + * tags: [Owner] + * summary: Block bookings for a venue (no approval needed) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Bookings blocked successfully + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.post( + '/venue/:venueId/block-bookings', + verifyAccessToken, + requirePermission(P.venues.update), + validateParams(validator.analyticsParamsSchema), + ownerTenantMiddleware, + idempotencyMiddleware(), + controller.blockBookings +); + +/** + * @openapi + * /owner/venue/{venueId}/block-bookings: + * delete: + * tags: [Owner] + * summary: Remove temporary booking block + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Booking block removed + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.delete( + '/venue/:venueId/block-bookings', + verifyAccessToken, + requirePermission(P.venues.update), + validateParams(validator.analyticsParamsSchema), + ownerTenantMiddleware, + idempotencyMiddleware(), + controller.unblockBookings +); + +/** + * @openapi + * /owner/venue/{venueId}/activate: + * post: + * tags: [Owner] + * summary: Reactivate venue from Inactive to Approved + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Venue reactivated successfully + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.post( + '/venue/:venueId/activate', + verifyAccessToken, + requirePermission(P.venues.update), + validateParams(validator.analyticsParamsSchema), + ownerTenantMiddleware, + idempotencyMiddleware(), + controller.activateVenue +); + +/** + * @openapi + * /owner/venue/{venueId}/delete-request: + * post: + * tags: [Owner] + * summary: Request venue deletion (requires admin approval) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [reason] + * properties: + * reason: + * type: string + * minLength: 10 + * maxLength: 500 + * responses: + * 200: + * description: Deletion request submitted + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router.post( + '/venue/:venueId/delete-request', + verifyAccessToken, + requirePermission(P.venues.update), + validateParams(validator.analyticsParamsSchema), + validateBody(validator.deleteRequestSchema), + ownerTenantMiddleware, + idempotencyMiddleware(), + controller.requestDeleteVenue +); + +/** + * @openapi + * /owner/bookings/{bookingId}/mark-paid: + * patch: + * tags: [Owner] + * summary: Mark an offline booking as paid + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: bookingId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Booking marked as paid + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * 404: + * description: Booking not found + */ +router + .route('/bookings/:bookingId/mark-paid') + .patch( + verifyAccessToken, + requirePermission(P.bookings.update), + ownerTenantMiddleware, + controller.markBookingAsPaid + ); + +/** + * @openapi + * /owner/bookings/{bookingId}/cancel-pending: + * patch: + * tags: [Owner] + * summary: Cancel a pending offline booking + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: bookingId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Pending offline booking cancelled + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * 404: + * description: Booking not found + */ +router + .route('/bookings/:bookingId/cancel-pending') + .patch( + verifyAccessToken, + requirePermission(P.bookings.update), + ownerTenantMiddleware, + controller.cancelPendingOfflineBooking + ); + +export default router; diff --git a/server/src/modules/owner/owner.service.ts b/server/src/modules/owner/owner.service.ts new file mode 100644 index 0000000000..4ae9605504 --- /dev/null +++ b/server/src/modules/owner/owner.service.ts @@ -0,0 +1,254 @@ +import { NotFoundError, ConflictError } from '../../utils/errors'; +import { buildPaginationMeta } from '../../utils/paginationUtils'; +import * as repo from './owner.repository'; +import type { offlineBookingSchema } from './owner.validator'; + +import type { z } from 'zod'; +import { fetchActiveConflicts } from '../booking/booking.repository'; +import { checkOverlap } from '../../utils/timeUtils'; +import * as venueRepo from '../venue/venue.repository'; +import * as venueWorkflow from '../venue/venue.workflow'; +import { requireOwnVenue } from '../venue/venue.ownership'; +import { ReviewIntent } from '../../constants/venue.constants'; +import { VenueModel } from '../venue/venue.model'; +import { BookingModel } from '../booking/models/booking.model'; +import { BookingStatus } from '../../constants/booking.constants'; +import mongoose from 'mongoose'; +import type { IVenue } from '../venue/venue.types'; + +export function getDateThreshold(daysAgo: number): string { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + return date.toISOString().split('T')[0]; +} + +export async function getVenueAnalyticsService( + venueId: string +): Promise<{ months: Record[] }> { + const months = await repo.getVenueAnalyticsData(venueId); + return { months }; +} + +export async function getVenueBookingsService( + venueId: string, + page: number, + limit: number +): Promise<{ bookings: Record[]; pagination: unknown }> { + const skip = (page - 1) * limit; + + const [bookings, totalCount] = await Promise.all([ + repo.getVenueBookingsPaginated(venueId, skip, limit), + repo.countVenueBookings(venueId), + ]); + + return { + bookings, + pagination: buildPaginationMeta(totalCount, { page, limit, skip, sort: '-date' }), + }; +} + +export async function createOfflineBookingService( + userId: string, + dto: z.infer +): Promise<{ bookingId: string }> { + const venue = await requireOwnVenue(dto.venueId, userId); + if (venue.status === 'Inactive') { + throw new ConflictError('Cannot create offline booking: venue is currently inactive'); + } + + const conflicts = await fetchActiveConflicts(dto.venueId, dto.date); + if (checkOverlap(dto.startTime, dto.endTime, conflicts)) { + throw new ConflictError( + 'This slot overlaps with an existing booking or hold for the selected date.' + ); + } + + const booking = await repo.createOfflineBookingRecord( + dto.venueId, + userId, + dto.date, + dto.startTime, + dto.endTime, + dto.amountPaid, + dto.customerName, + dto.phone + ); + return { bookingId: String(booking._id) }; +} + +export async function blockDatesService(venueId: string, dates: string[]): Promise { + const conflictingBooking = await repo.findConflictingBookingForDates(venueId, dates); + + if (conflictingBooking) { + throw new ConflictError( + `Cannot block date ${conflictingBooking.date} because it has a confirmed booking.` + ); + } + + const dateObjects = dates.map((d) => new Date(`${d}T00:00:00Z`)); + const updatedVenue = await repo.addBlockedDatesToVenue(venueId, dateObjects); + return updatedVenue ? updatedVenue.blockedDates : []; +} + +export async function unblockDatesService(venueId: string, dates: string[]): Promise { + const dateObjects = dates.map((d) => new Date(`${d}T00:00:00Z`)); + const updatedVenue = await repo.removeBlockedDatesFromVenue(venueId, dateObjects); + return updatedVenue ? updatedVenue.blockedDates : []; +} + +export async function getVenueSettingsService(venueId: string): Promise { + const venue = await venueRepo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + return venue; +} + +export async function requestInactivityService( + venueId: string, + userId: string, + reason?: string +): Promise { + const venue = await requireOwnVenue(venueId, userId); + venueWorkflow.canRequestInactivity(venue); + + const $set: Record = { + 'inactivity.requestedAt': new Date(), + 'pendingReview.intent': ReviewIntent.INACTIVITY_REQUEST, + 'pendingReview.requestedAt': new Date(), + }; + if (reason) $set['pendingReview.details.reason'] = reason; + + const updated = await VenueModel.findByIdAndUpdate(venueId, { $set }, { new: true }).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; +} + +export async function withdrawInactivityService(venueId: string, userId: string): Promise { + const venue = await requireOwnVenue(venueId, userId); + + if (venue.pendingReview?.intent !== ReviewIntent.INACTIVITY_REQUEST) { + throw new ConflictError('No pending inactivity request to withdraw'); + } + + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { $unset: { pendingReview: '', 'inactivity.requestedAt': '' } }, + { new: true } + ).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; +} + +export async function blockBookingsService(venueId: string, userId: string): Promise { + await requireOwnVenue(venueId, userId); + + const today = new Date().toISOString().split('T')[0]; + const latestBooking = await BookingModel.findOne({ + venueId: new mongoose.Types.ObjectId(venueId), + status: BookingStatus.CONFIRMED, + date: { $gte: today }, + }) + .sort({ date: -1 }) + .lean() + .exec(); + + let blockedAfterDate: Date; + if (latestBooking) { + const bookingDate = new Date(latestBooking.date + 'T00:00:00Z'); + blockedAfterDate = new Date(bookingDate.getTime() + 24 * 60 * 60 * 1000); + } else { + blockedAfterDate = new Date(today + 'T00:00:00Z'); + } + + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { $set: { temporaryBlockAfterDate: blockedAfterDate } }, + { new: true } + ).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; +} + +export async function unblockBookingsService(venueId: string, userId: string): Promise { + await requireOwnVenue(venueId, userId); + + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { $unset: { temporaryBlockAfterDate: '' } }, + { new: true } + ).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; +} + +export async function activateVenueService(venueId: string, userId: string): Promise { + const venue = await requireOwnVenue(venueId, userId); + venueWorkflow.canReactivate(venue); + + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { + $set: { status: 'Approved', 'inactivity.lastInactiveAt': new Date() }, + $unset: { + pendingReview: '', + 'inactivity.requestedAt': '', + 'inactivity.approvedAt': '', + 'inactivity.inactiveAt': '', + 'inactivity.blockedAfterDate': '', + temporaryBlockAfterDate: '', + }, + }, + { new: true } + ).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; +} + +export async function markBookingAsPaidService(bookingId: string): Promise { + const updated = await repo.markBookingAsPaid(bookingId); + if (!updated) { + throw new NotFoundError('Booking not found or already paid'); + } +} + +export async function cancelPendingOfflineBookingService(bookingId: string): Promise { + const updated = await repo.cancelPendingOfflineBooking(bookingId); + if (!updated) { + throw new NotFoundError('Booking not found or already processed'); + } +} + +export async function requestDeleteVenueService( + venueId: string, + userId: string, + reason: string +): Promise { + const venue = await requireOwnVenue(venueId, userId); + venueWorkflow.canRequestDelete(venue); + + const today = new Date().toISOString().split('T')[0]; + const futureBookingCount = await BookingModel.countDocuments({ + venueId: new mongoose.Types.ObjectId(venueId), + status: BookingStatus.CONFIRMED, + date: { $gte: today }, + }); + + if (futureBookingCount > 0) { + throw new ConflictError('Cannot request deletion: venue has future confirmed bookings'); + } + + const updateObj: Record = { + $set: { + 'pendingReview.intent': ReviewIntent.DELETION_REQUEST, + 'pendingReview.requestedAt': new Date(), + 'pendingReview.details.reason': reason, + }, + }; + + if (venue.pendingReview?.intent === ReviewIntent.INACTIVITY_REQUEST) { + updateObj.$unset = { 'inactivity.requestedAt': '' }; + } + + const updated = await VenueModel.findByIdAndUpdate(venueId, updateObj, { new: true }).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; +} diff --git a/server/src/modules/owner/owner.validator.ts b/server/src/modules/owner/owner.validator.ts new file mode 100644 index 0000000000..7d8d8a5156 --- /dev/null +++ b/server/src/modules/owner/owner.validator.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +export const analyticsParamsSchema = z.object({ + venueId: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid venue ID'), +}); + +export const blockDatesSchema = z.object({ + dates: z + .array( + z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format (YYYY-MM-DD)') + .refine((dateStr) => { + const d = new Date(`${dateStr}T00:00:00Z`); + const today = new Date(); + // Use UTC to match the Date object + const todayUtc = new Date( + Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) + ); + const sixMonths = new Date(todayUtc); + sixMonths.setUTCMonth(sixMonths.getUTCMonth() + 6); + return d >= todayUtc && d <= sixMonths; + }, 'Dates must be from today up to 6 months in the future') + ) + .min(1, 'At least one date is required'), +}); + +export const unblockDatesSchema = z.object({ + dates: z + .array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format (YYYY-MM-DD)')) + .min(1, 'At least one date is required'), +}); + +export const offlineBookingSchema = z + .object({ + venueId: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid venue ID'), + customerName: z.string().trim().min(2, 'Customer name is required'), + phone: z.string().trim().min(10, 'Valid phone number required'), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'), + startTime: z.number().int().min(0).max(1439, 'startTime must be minutes from midnight'), + endTime: z.number().int().min(1).max(1440, 'endTime must be minutes from midnight'), + amountPaid: z.number().positive('Amount must be positive'), + }) + .refine((data) => data.endTime > data.startTime, { + message: 'endTime must be after startTime', + path: ['endTime'], + }); + +export const reviewParamsSchema = z.object({ + venueId: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid venue ID'), + reviewId: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid review ID'), +}); + +export const ownerReplySchema = z.object({ + text: z.string().trim().min(1, 'Reply cannot be empty').max(500, 'Reply too long'), +}); + +export const reportReviewSchema = z.object({ + action: z.enum(['hide', 'flag']), + reason: z.string().trim().min(10, 'Reason must be at least 10 characters').max(500), +}); + +export const inactivityRequestSchema = z.object({ + reason: z.string().trim().min(10).max(500).optional(), +}); + +export const deleteRequestSchema = z.object({ + reason: z.string().trim().min(10).max(500), +}); diff --git a/server/src/modules/owner/owner.workflow.ts b/server/src/modules/owner/owner.workflow.ts new file mode 100644 index 0000000000..ef12eb5395 --- /dev/null +++ b/server/src/modules/owner/owner.workflow.ts @@ -0,0 +1,39 @@ +import * as repo from './owner.repository'; +import * as service from './owner.service'; +import type { IBooking } from '../booking/booking.types'; + +export async function getVenueAvailabilityCalendarWorkflow(venueId: string): Promise<{ + bookedDates: string[]; + blockedDates: string[]; + workingDays: unknown; + temporaryBlockAfterDate: string | null; + inactivityBlockedAfterDate: string | null; +} | null> { + const venue = await repo.getVenueBlockedDatesAndWorkingDays(venueId); + if (!venue) { + return null; + } + + const dateThreshold = service.getDateThreshold(30); + + const bookings = await repo.getConfirmedBookingsAfterDate(venueId, dateThreshold); + + const bookedDates = [...new Set(bookings.map((b: IBooking) => b.date))]; + + // Blocked dates - YYYY-MM-DD strings + const blockedDates = venue.blockedDates.map((d: Date) => new Date(d).toISOString().split('T')[0]); + + const toDateStr = (d: Date | undefined | null): string | null => { + if (!d) return null; + const date = new Date(d); + return date.toISOString().split('T')[0]; + }; + + return { + bookedDates, + blockedDates, + workingDays: venue.workingDays, + temporaryBlockAfterDate: toDateStr(venue.temporaryBlockAfterDate), + inactivityBlockedAfterDate: toDateStr(venue.inactivity?.blockedAfterDate), + }; +} diff --git a/server/src/modules/rbac/rbac.controller.ts b/server/src/modules/rbac/rbac.controller.ts new file mode 100644 index 0000000000..1641566ca5 --- /dev/null +++ b/server/src/modules/rbac/rbac.controller.ts @@ -0,0 +1,24 @@ +import type { Request, Response } from 'express'; +import { ResponseUtil } from '../../utils/responseUtils'; +import * as service from './rbac.service'; + +export const rbacController = { + getCacheStats: (_req: Request, res: Response): void => { + const stats = service.getCacheStats(); + ResponseUtil.success(res, 'Cache stats retrieved', { + totalRoles: stats.size, + totalPermissions: stats.entries.reduce((sum, entry) => sum + entry.permissionCount, 0), + roles: stats.entries, + }); + }, + + clearCache: (_req: Request, res: Response): void => { + service.clearCache(); + ResponseUtil.success(res, 'Permission cache cleared'); + }, + + invalidateRoleCache: (req: Request, res: Response): void => { + service.invalidateRoleCache(String(req.params.roleId)); + ResponseUtil.success(res, `Cache invalidated for role ${String(req.params.roleId)}`); + }, +}; diff --git a/server/src/modules/rbac/rbac.router.ts b/server/src/modules/rbac/rbac.router.ts new file mode 100644 index 0000000000..8e21443b6c --- /dev/null +++ b/server/src/modules/rbac/rbac.router.ts @@ -0,0 +1,87 @@ +import { Router } from 'express'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { requireSuperAdmin } from '../../middlewares/rbac.middleware'; +import { rbacController } from './rbac.controller'; + +const router: Router = Router(); + +/** + * @openapi + * /rbac/cache: + * get: + * tags: [RBAC] + * summary: Get permission cache statistics (super-admin only) + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Cache stats — hit rate, size, entries + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * data: + * type: object + * properties: + * size: + * type: integer + * entries: + * type: array + * items: + * type: string + * 401: + * description: Not authenticated + * 403: + * description: Super-admin role required + * delete: + * tags: [RBAC] + * summary: Clear the entire permission cache (super-admin only) + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Permission cache cleared + * 401: + * description: Not authenticated + * 403: + * description: Super-admin role required + */ +router + .route('/cache') + .get(verifyAccessToken, requireSuperAdmin, rbacController.getCacheStats) + .delete(verifyAccessToken, requireSuperAdmin, rbacController.clearCache); + +/** + * @openapi + * /rbac/cache/{roleId}: + * delete: + * tags: [RBAC] + * summary: Invalidate the permission cache for a specific role (super-admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: roleId + * required: true + * description: MongoDB ObjectId of the role whose cache entry should be cleared + * schema: + * type: string + * example: 64b1f2c3d4e5f6a7b8c9d0e3 + * responses: + * 200: + * description: Cache entry invalidated for the role + * 401: + * description: Not authenticated + * 403: + * description: Super-admin role required + * 404: + * description: Role not found in cache + */ +router + .route('/cache/:roleId') + .delete(verifyAccessToken, requireSuperAdmin, rbacController.invalidateRoleCache); + +export { router as rbacRouter }; diff --git a/server/src/modules/rbac/rbac.service.ts b/server/src/modules/rbac/rbac.service.ts new file mode 100644 index 0000000000..59d8249b87 --- /dev/null +++ b/server/src/modules/rbac/rbac.service.ts @@ -0,0 +1,14 @@ +import { getStats, clearAll, invalidateRole } from '../../services/cache/permission-cache.service'; +import type { CacheStatEntry } from '../../services/cache/permission-cache.service'; + +export function getCacheStats(): { size: number; entries: CacheStatEntry[] } { + return getStats(); +} + +export function clearCache(): void { + clearAll(); +} + +export function invalidateRoleCache(roleId: string): void { + invalidateRole(roleId); +} diff --git a/server/src/modules/review/review.controller.ts b/server/src/modules/review/review.controller.ts new file mode 100644 index 0000000000..7e9d9c488d --- /dev/null +++ b/server/src/modules/review/review.controller.ts @@ -0,0 +1,197 @@ +import type { Request, Response } from 'express'; +import { ResponseUtil } from '../../utils/responseUtils'; +import * as service from './review.service'; +import { handleError } from '../../utils/errors'; +import type { UpdateReviewDTO, ModerateReviewDTO } from './review.types'; + +export const submitReview = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const venueId = Array.isArray(req.params.venueId) ? req.params.venueId[0] : req.params.venueId; + const { rating, comment } = req.body as { rating?: number; comment?: string }; + + const review = await service.submitReview(userId, venueId, { rating, comment }); + ResponseUtil.created(res, 'Review submitted successfully', review); + } catch (err) { + handleError(res, err, 'submitReview'); + } +}; + +export const upsertRating = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const venueId = Array.isArray(req.params.venueId) ? req.params.venueId[0] : req.params.venueId; + const { rating } = req.body as { rating: number }; + + const review = await service.upsertRating(userId, venueId, rating); + ResponseUtil.success(res, 'Rating updated successfully', review); + } catch (err) { + handleError(res, err, 'upsertRating'); + } +}; + +export const getMyRating = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const venueId = Array.isArray(req.params.venueId) ? req.params.venueId[0] : req.params.venueId; + const rating = await service.getMyRating(userId, venueId); + ResponseUtil.success(res, 'Rating retrieved successfully', { rating }); + } catch (err) { + handleError(res, err, 'getMyRating'); + } +}; + +export const updateReview = async (req: Request, res: Response): Promise => { + try { + const user = req.user; + if (!user?.userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const dto = req.body as UpdateReviewDTO; + const userId = user.userId; + const requesterRole = user.role.name; + const review = await service.updateReview(userId, id, dto, requesterRole); + ResponseUtil.success(res, 'Review updated successfully', review); + } catch (err) { + handleError(res, err, 'updateReview'); + } +}; + +export const deleteReview = async (req: Request, res: Response): Promise => { + try { + const user = req.user; + if (!user?.userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const userId = user.userId; + const requesterRole = user.role.name; + await service.deleteReview(userId, id, requesterRole); + ResponseUtil.success(res, 'Review deleted successfully'); + } catch (err) { + handleError(res, err, 'deleteReview'); + } +}; + +export const getVenueReviews = async (req: Request, res: Response): Promise => { + try { + const venueId = Array.isArray(req.params.venueId) ? req.params.venueId[0] : req.params.venueId; + const paginationParams = req.pagination ?? { page: 1, limit: 10, skip: 0, sort: '' }; + const result = await service.getVenueReviews(venueId, paginationParams); + ResponseUtil.paginated( + res, + 'Reviews retrieved successfully', + result.reviews, + result.pagination, + 'reviews' + ); + } catch (err) { + handleError(res, err, 'getVenueReviews'); + } +}; + +export const getFlaggedReviews = async (req: Request, res: Response): Promise => { + try { + const paginationParams = req.pagination ?? { page: 1, limit: 20, skip: 0, sort: '' }; + const result = await service.getFlaggedReviews(paginationParams); + ResponseUtil.paginated( + res, + 'Flagged reviews retrieved successfully', + result.reviews, + result.pagination, + 'reviews' + ); + } catch (err) { + handleError(res, err, 'getFlaggedReviews'); + } +}; + +export const moderateReview = async (req: Request, res: Response): Promise => { + try { + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const dto = req.body as ModerateReviewDTO; + const review = await service.moderateReview(id, dto, adminId); + ResponseUtil.success(res, 'Review moderated successfully', review); + } catch (err) { + handleError(res, err, 'moderateReview'); + } +}; + +export const getOwnerVenueReviews = async (req: Request, res: Response): Promise => { + try { + const venueId = Array.isArray(req.params.venueId) ? req.params.venueId[0] : req.params.venueId; + const { page, limit, skip } = req.pagination ?? { page: 1, limit: 10, skip: 0, sort: '' }; + + const result = await service.getOwnerVenueReviews(venueId, { page, limit, skip, sort: '' }); + ResponseUtil.paginated( + res, + 'Venue reviews retrieved', + result.reviews, + result.pagination, + 'reviews' + ); + } catch (err) { + handleError(res, err, 'getOwnerVenueReviews'); + } +}; + +export const replyToReview = async (req: Request, res: Response): Promise => { + try { + const venueId = Array.isArray(req.params.venueId) ? req.params.venueId[0] : req.params.venueId; + const reviewId = Array.isArray(req.params.reviewId) + ? req.params.reviewId[0] + : req.params.reviewId; + const { text } = req.body as { text: string }; + + const review = await service.replyToReview(venueId, reviewId, text); + ResponseUtil.success(res, 'Reply added successfully', review); + } catch (err) { + handleError(res, err, 'replyToReview'); + } +}; + +export const reportReview = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const reviewId = Array.isArray(req.params.reviewId) + ? req.params.reviewId[0] + : req.params.reviewId; + const { reason } = req.body as { reason: string }; + + const review = await service.flagReview(reviewId, reason); + ResponseUtil.success(res, 'Review flagged successfully', review); + } catch (err) { + handleError(res, err, 'reportReview'); + } +}; diff --git a/server/src/modules/review/review.model.ts b/server/src/modules/review/review.model.ts new file mode 100644 index 0000000000..524c5b7173 --- /dev/null +++ b/server/src/modules/review/review.model.ts @@ -0,0 +1,117 @@ +import mongoose, { Schema } from 'mongoose'; +import type { Document } from 'mongoose'; + +export interface IReview extends Document { + venueId: mongoose.Types.ObjectId; + userId: mongoose.Types.ObjectId; + bookingId?: mongoose.Types.ObjectId | null; + rating?: number; // 1-5, optional (upserted doc may have no comment) + comment?: string; // optional (rating-only doc may have no comment) + status: 'visible' | 'flagged' | 'removed'; + moderationReason?: string; + moderatedBy?: mongoose.Types.ObjectId; + moderatedAt?: Date; + editedAt?: Date; + ownerReply?: { text: string; repliedAt: Date }; + hideRequestedBy?: mongoose.Types.ObjectId; + hideRequestReason?: string; + hideRequestedAt?: Date; + hideRequestStatus: 'none' | 'pending' | 'approved' | 'rejected'; + createdAt: Date; + updatedAt: Date; +} + +const ReviewSchema = new Schema( + { + venueId: { + type: Schema.Types.ObjectId, + ref: 'Venues', + required: true, + index: true, + }, + userId: { + type: Schema.Types.ObjectId, + ref: 'Users', + required: true, + index: true, + }, + bookingId: { + type: Schema.Types.ObjectId, + ref: 'Bookings', + default: null, + }, + rating: { + type: Number, + min: 1, + max: 5, + }, + comment: { + type: String, + maxlength: 1000, + trim: true, + }, + status: { + type: String, + enum: ['visible', 'flagged', 'removed'], + default: 'visible', + index: true, + }, + moderationReason: { + type: String, + maxlength: 500, + }, + moderatedBy: { + type: Schema.Types.ObjectId, + ref: 'Users', + }, + moderatedAt: { + type: Date, + }, + editedAt: { + type: Date, + }, + ownerReply: { + text: { type: String, maxlength: 500, trim: true }, + repliedAt: { type: Date }, + }, + hideRequestedBy: { + type: Schema.Types.ObjectId, + ref: 'Users', + }, + hideRequestReason: { + type: String, + maxlength: 500, + }, + hideRequestedAt: { + type: Date, + }, + hideRequestStatus: { + type: String, + enum: ['none', 'pending', 'approved', 'rejected'], + default: 'none', + index: true, + }, + }, + { timestamps: true } +); + +// Index for venue reviews listing (public view filters to visible) +ReviewSchema.index({ venueId: 1, status: 1, createdAt: -1 }, { name: 'idx_venue_status_recency' }); + +// Partial unique index: only one rating per (userId, venueId) +ReviewSchema.index( + { userId: 1, venueId: 1 }, + { + unique: true, + partialFilterExpression: { rating: { $exists: true } }, + name: 'idx_user_venue_rating', + } +); + +// Index for admin hide-request moderation queue +ReviewSchema.index( + { hideRequestStatus: 1, hideRequestedAt: -1 }, + { name: 'idx_hide_request_queue' } +); + +export const ReviewModel = mongoose.model('Reviews', ReviewSchema, 'Reviews'); diff --git a/server/src/modules/review/review.ownership.ts b/server/src/modules/review/review.ownership.ts new file mode 100644 index 0000000000..e41c7a0140 --- /dev/null +++ b/server/src/modules/review/review.ownership.ts @@ -0,0 +1,41 @@ +import { ReviewModel } from './review.model'; +import { ForbiddenError, NotFoundError } from '../../utils/errors'; +import mongoose from 'mongoose'; + +const toObjectId = (id: string): mongoose.Types.ObjectId => { + return new mongoose.Types.ObjectId(id); +}; + +export async function assertReviewOwner(reviewId: string, userId: string): Promise { + const review = await ReviewModel.findById(toObjectId(reviewId)).exec(); + if (!review) { + throw new NotFoundError('Review not found'); + } + if (review.userId.toString() !== userId) { + throw new ForbiddenError('You do not have permission to modify this review'); + } +} + +export async function isReviewEditableByOwner(reviewId: string, userId: string): Promise { + const review = await ReviewModel.findById(toObjectId(reviewId)).exec(); + if (!review) { + return false; + } + + if (review.userId.toString() !== userId) { + return false; + } + + // Check 30-day edit window from creation + const createdAt = review.createdAt; + const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000; + const now = Date.now(); + + return now - createdAt.getTime() <= thirtyDaysInMs; +} + +export function requireOwnReview(userId: string): (reviewId: string) => Promise { + return async (reviewId: string) => { + await assertReviewOwner(reviewId, userId); + }; +} diff --git a/server/src/modules/review/review.repository.ts b/server/src/modules/review/review.repository.ts new file mode 100644 index 0000000000..912a3e81e8 --- /dev/null +++ b/server/src/modules/review/review.repository.ts @@ -0,0 +1,341 @@ +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import { buildPaginationMeta } from '../../utils/paginationUtils'; +import { ReviewModel, type IReview } from './review.model'; +import type { UpdateReviewDTO, ModerateReviewDTO } from './review.types'; +import mongoose from 'mongoose'; + +const toObjectId = (id: string): mongoose.Types.ObjectId => { + return new mongoose.Types.ObjectId(id); +}; + +export async function upsertRating( + userId: string, + venueId: string, + rating: number +): Promise { + const review = await ReviewModel.findOneAndUpdate( + { userId: toObjectId(userId), venueId: toObjectId(venueId), rating: { $exists: true } }, + { rating, editedAt: new Date() }, + { upsert: true, new: true, setDefaultsOnInsert: true, runValidators: true } + ).exec(); + return review; +} + +export async function createComment( + userId: string, + venueId: string, + comment: string +): Promise { + const review = new ReviewModel({ + userId: toObjectId(userId), + venueId: toObjectId(venueId), + comment, + status: 'visible', + }); + return review.save(); +} + +export async function findRatingsForUsers( + venueId: string, + userIds: string[] +): Promise> { + if (userIds.length === 0) { + return new Map(); + } + + const validUserIds = userIds.filter((id) => { + try { + toObjectId(id); + return true; + } catch { + return false; + } + }); + + if (validUserIds.length === 0) { + return new Map(); + } + + const ratings = await ReviewModel.find({ + venueId: toObjectId(venueId), + userId: { $in: validUserIds.map(toObjectId) }, + rating: { $exists: true }, + status: 'visible', + }) + .select('userId rating') + .exec(); + + const map = new Map(); + ratings.forEach((r) => { + if (r.rating) map.set(r.userId.toString(), r.rating); + }); + return map; +} + +export async function updateReview( + reviewId: string, + dto: UpdateReviewDTO +): Promise { + const updates: Record = { editedAt: new Date() }; + if (dto.rating !== undefined) updates.rating = dto.rating; + if (dto.comment !== undefined) updates.comment = dto.comment; + + return ReviewModel.findByIdAndUpdate(toObjectId(reviewId), updates, { + new: true, + runValidators: true, + }).exec(); +} + +export async function deleteReview(reviewId: string): Promise { + await ReviewModel.deleteOne({ _id: toObjectId(reviewId) }).exec(); +} + +export async function findVenueReviews( + venueId: string, + paginationParams: PaginationParams +): Promise> { + const { limit, skip } = paginationParams; + + const [reviews, total] = await Promise.all([ + ReviewModel.find({ + venueId: toObjectId(venueId), + status: 'visible', + comment: { $exists: true, $ne: '' }, + }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .populate('userId', 'username') + .exec(), + + ReviewModel.countDocuments({ + venueId: toObjectId(venueId), + status: 'visible', + comment: { $exists: true, $ne: '' }, + }).exec(), + ]); + + return { + reviews, + pagination: buildPaginationMeta(total, paginationParams), + }; +} + +export async function getVenueRatingAggregate( + venueId: string +): Promise<{ avgRating: number; reviewCount: number }> { + const result = await ReviewModel.aggregate([ + { + $match: { + venueId: toObjectId(venueId), + status: 'visible', + rating: { $exists: true }, + }, + }, + { + $group: { + _id: null, + avgRating: { $avg: '$rating' }, + reviewCount: { $sum: 1 }, + }, + }, + ]).exec(); + + if (!result.length) { + return { avgRating: 0, reviewCount: 0 }; + } + + const agg = result[0] as { avgRating: number; reviewCount: number }; + return { + avgRating: Math.round(agg.avgRating * 10) / 10, + reviewCount: agg.reviewCount, + }; +} + +export async function findReviewById(reviewId: string): Promise { + return ReviewModel.findById(toObjectId(reviewId)).exec(); +} + +export async function moderateReview( + reviewId: string, + dto: ModerateReviewDTO, + moderatorId: string +): Promise { + const updates: Record = { + moderatedBy: toObjectId(moderatorId), + moderatedAt: new Date(), + }; + + if (dto.action === 'flag') { + updates.status = 'flagged'; + updates.moderationReason = dto.reason; + } else if (dto.action === 'remove') { + updates.status = 'removed'; + updates.moderationReason = dto.reason; + } else if (dto.action === 'restore') { + updates.status = 'visible'; + updates.moderationReason = null; + } + + return ReviewModel.findByIdAndUpdate(toObjectId(reviewId), updates, { new: true }).exec(); +} + +export async function findFlaggedReviews( + paginationParams: PaginationParams +): Promise> { + const { limit, skip } = paginationParams; + + const [reviews, total] = await Promise.all([ + ReviewModel.find({ + $or: [{ status: 'flagged' }, { status: 'removed' }], + }) + .sort({ moderatedAt: -1, createdAt: -1 }) + .skip(skip) + .limit(limit) + .populate('userId', 'username') + .populate('venueId', 'name') + .exec(), + + ReviewModel.countDocuments({ + $or: [{ status: 'flagged' }, { status: 'removed' }], + }).exec(), + ]); + + return { + reviews, + pagination: buildPaginationMeta(total, paginationParams), + }; +} + +export async function findUserReviewedBookings(userId: string): Promise> { + const reviews = await ReviewModel.find({ + userId: toObjectId(userId), + bookingId: { $exists: true }, + }) + .select('bookingId') + .lean() + .exec(); + + const bookingIds = reviews + .map((r) => { + if (r.bookingId) return r.bookingId.toString(); + return null; + }) + .filter((id): id is string => id !== null); + return new Set(bookingIds); +} + +export async function findVenueReviewsForOwner( + venueId: string, + paginationParams: PaginationParams +): Promise> { + const { limit, skip } = paginationParams; + + const [reviews, total] = await Promise.all([ + ReviewModel.find({ venueId: toObjectId(venueId) }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .populate('userId', 'username email') + .exec(), + + ReviewModel.countDocuments({ venueId: toObjectId(venueId) }).exec(), + ]); + + return { + reviews, + pagination: buildPaginationMeta(total, paginationParams), + }; +} + +export async function addOwnerReply( + reviewId: string, + venueId: string, + text: string +): Promise { + return ReviewModel.findOneAndUpdate( + { _id: toObjectId(reviewId), venueId: toObjectId(venueId) }, + { + ownerReply: { + text, + repliedAt: new Date(), + }, + }, + { new: true } + ).exec(); +} + +export async function requestHideReview( + reviewId: string, + venueId: string, + ownerId: string, + reason: string +): Promise { + return ReviewModel.findOneAndUpdate( + { _id: toObjectId(reviewId), venueId: toObjectId(venueId) }, + { + hideRequestedBy: toObjectId(ownerId), + hideRequestReason: reason, + hideRequestedAt: new Date(), + hideRequestStatus: 'pending', + }, + { new: true } + ).exec(); +} + +export async function flagReview(reviewId: string, reason: string): Promise { + return ReviewModel.findByIdAndUpdate( + toObjectId(reviewId), + { + status: 'flagged', + moderationReason: reason, + }, + { new: true } + ).exec(); +} + +export async function findPendingHideRequests( + paginationParams: PaginationParams +): Promise> { + const { limit, skip } = paginationParams; + + const [reviews, total] = await Promise.all([ + ReviewModel.find({ hideRequestStatus: 'pending' }) + .sort({ hideRequestedAt: -1 }) + .skip(skip) + .limit(limit) + .populate('userId', 'username email') + .populate({ + path: 'venueId', + select: 'name ownerUserId', + populate: { path: 'ownerUserId', select: 'username email' }, + }) + .exec(), + + ReviewModel.countDocuments({ hideRequestStatus: 'pending' }).exec(), + ]); + + return { + reviews, + pagination: buildPaginationMeta(total, paginationParams), + }; +} + +export async function resolveHideRequest( + reviewId: string, + action: 'approve' | 'reject', + adminId: string +): Promise { + const updates: Record = {}; + + if (action === 'approve') { + updates.status = 'removed'; + updates.hideRequestStatus = 'approved'; + updates.moderatedBy = toObjectId(adminId); + updates.moderatedAt = new Date(); + } else { + updates.hideRequestStatus = 'rejected'; + } + + return ReviewModel.findByIdAndUpdate(toObjectId(reviewId), updates, { new: true }).exec(); +} diff --git a/server/src/modules/review/review.router.ts b/server/src/modules/review/review.router.ts new file mode 100644 index 0000000000..ca5f239909 --- /dev/null +++ b/server/src/modules/review/review.router.ts @@ -0,0 +1,207 @@ +import { Router } from 'express'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { requireRole, requirePermission } from '../../middlewares/rbac.middleware'; +import { PERMISSIONS as P } from '../../constants/permissions'; +import { paginationMiddleware } from '../../middlewares/pagination.middleware'; +import * as controller from './review.controller'; + +const router: Router = Router(); + +/** + * @openapi + * /reviews/venue/{venueId}: + * get: + * tags: [Reviews] + * summary: Get reviews for a venue (public) + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Reviews for the venue + * post: + * tags: [Reviews] + * summary: Submit a rating and/or comment for a venue + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * rating: + * type: number + * comment: + * type: string + * responses: + * 201: + * description: Review submitted successfully + * 401: + * description: Not authenticated + */ +router + .route('/venue/:venueId') + .get(paginationMiddleware(), controller.getVenueReviews) + .post(verifyAccessToken, requirePermission(P.reviews.create), controller.submitReview); + +/** + * @openapi + * /reviews/venue/{venueId}/my-rating: + * get: + * tags: [Reviews] + * summary: Get my rating for a venue + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: My rating + */ +router.route('/venue/:venueId/my-rating').get(verifyAccessToken, controller.getMyRating); + +/** + * @openapi + * /reviews/{id}: + * patch: + * tags: [Reviews] + * summary: Update a review (owner only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * rating: + * type: number + * comment: + * type: string + * responses: + * 200: + * description: Review updated successfully + * 401: + * description: Not authenticated + * 404: + * description: Review not found + * delete: + * tags: [Reviews] + * summary: Delete a review (owner only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Review deleted successfully + * 401: + * description: Not authenticated + * 404: + * description: Review not found + */ +router + .route('/:id') + .patch(verifyAccessToken, requirePermission(P.reviews.update), controller.updateReview) + .delete(verifyAccessToken, requirePermission(P.reviews.delete), controller.deleteReview); + +/** + * @openapi + * /reviews/moderation/flagged: + * get: + * tags: [Reviews] + * summary: Get flagged/removed reviews (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Flagged reviews + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router + .route('/moderation/flagged') + .get( + verifyAccessToken, + requireRole('admin'), + paginationMiddleware(), + controller.getFlaggedReviews + ); + +/** + * @openapi + * /reviews/{id}/moderate: + * patch: + * tags: [Reviews] + * summary: Moderate a review (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * action: + * type: string + * enum: [flag, remove, restore] + * reason: + * type: string + * responses: + * 200: + * description: Review moderated successfully + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router + .route('/:id/moderate') + .patch(verifyAccessToken, requireRole('admin'), controller.moderateReview); + +export { router as reviewRouter }; diff --git a/server/src/modules/review/review.service.ts b/server/src/modules/review/review.service.ts new file mode 100644 index 0000000000..9ff946c666 --- /dev/null +++ b/server/src/modules/review/review.service.ts @@ -0,0 +1,406 @@ +import * as repo from './review.repository'; +import * as bookingRepo from '../booking/booking.repository'; +import { isReviewEditableByOwner } from './review.ownership'; +import type { UpdateReviewDTO, ModerateReviewDTO } from './review.types'; +import type { IReview as IReviewModel } from './review.model'; +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import { VenueModel } from '../venue/venue.model'; +import { ConflictError, NotFoundError, ValidationError } from '../../utils/errors'; +import { logError, logWarn } from '../../utils/logger'; +import { enqueueEmailTask } from '../../services/email.repository'; +import { EmailIntent, EmailTaskStatus } from '../../constants/email.constants'; + +export async function submitReview( + userId: string, + venueId: string, + dto: { rating?: number; comment?: string } +): Promise { + if (!dto.rating && !dto.comment) { + throw new ValidationError('Must provide either a rating or a comment'); + } + + if (dto.comment?.trim()) { + const { isBannedForScope } = await import('../moderation/bannedUser.service.js'); + const isBanned = await isBannedForScope(userId, 'commenting', venueId); + if (isBanned) { + throw new ConflictError('You are currently banned from commenting.'); + } + } + + if (dto.rating && (!Number.isInteger(dto.rating) || dto.rating < 1 || dto.rating > 5)) { + throw new ValidationError('Rating must be an integer between 1 and 5'); + } + + let review: IReviewModel | null = null; + + if (dto.rating) { + review = await repo.upsertRating(userId, venueId, dto.rating); + } + + if (dto.comment?.trim()) { + const commentReview = await repo.createComment(userId, venueId, dto.comment.trim()); + review ??= commentReview; + } + + if (!review) { + throw new ValidationError('Failed to create review'); + } + + if (dto.rating) { + await recomputeVenueRating(venueId); + } + + return review; +} + +export async function upsertRating( + userId: string, + venueId: string, + rating: number +): Promise { + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { + throw new ValidationError('Rating must be an integer between 1 and 5'); + } + const review = await repo.upsertRating(userId, venueId, rating); + await recomputeVenueRating(venueId); + return review; +} + +export async function addComment( + userId: string, + venueId: string, + comment: string +): Promise { + if (!comment.trim()) { + throw new ValidationError('Comment cannot be empty'); + } + + const { isBannedForScope } = await import('../moderation/bannedUser.service.js'); + const isBanned = await isBannedForScope(userId, 'commenting', venueId); + if (isBanned) { + throw new ConflictError('You are currently banned from commenting.'); + } + + return repo.createComment(userId, venueId, comment.trim()); +} + +export async function getMyRating(userId: string, venueId: string): Promise { + const ratings = await repo.findRatingsForUsers(venueId, [userId]); + return ratings.get(userId) ?? null; +} + +export async function updateReview( + userId: string, + reviewId: string, + dto: UpdateReviewDTO, + requesterRole?: string +): Promise { + const review = await repo.findReviewById(reviewId); + if (!review) { + throw new NotFoundError('Review not found'); + } + + const isAdmin = requesterRole === 'admin' || requesterRole === 'superAdmin'; + const isOwner = review.userId.toString() === userId; + + if (!isOwner && !isAdmin) { + throw new NotFoundError('Review not found'); + } + + if (!isAdmin) { + const isEditable = await isReviewEditableByOwner(reviewId, userId); + if (!isEditable) { + throw new ValidationError('Review can only be edited within 30 days of creation'); + } + + if (dto.comment?.trim()) { + const { isBannedForScope } = await import('../moderation/bannedUser.service.js'); + const isBanned = await isBannedForScope(userId, 'commenting', review.venueId.toString()); + if (isBanned) { + throw new ConflictError('You are currently banned from commenting.'); + } + } + } + + const updated = await repo.updateReview(reviewId, dto); + if (!updated) { + throw new NotFoundError('Review not found'); + } + + if (dto.rating !== undefined) { + await recomputeVenueRating(updated.venueId.toString()); + } + + return updated; +} + +export async function deleteReview( + userId: string, + reviewId: string, + requesterRole?: string +): Promise { + const review = await repo.findReviewById(reviewId); + if (!review) { + throw new NotFoundError('Review not found'); + } + + const isAdmin = requesterRole === 'admin' || requesterRole === 'superAdmin'; + const isOwner = review.userId.toString() === userId; + + if (!isOwner && !isAdmin) { + throw new NotFoundError('Review not found'); + } + + if (!isAdmin) { + const isEditable = await isReviewEditableByOwner(reviewId, userId); + if (!isEditable) { + throw new ValidationError('Review can only be deleted within 30 days of creation'); + } + } + + const venueId = review.venueId.toString(); + await repo.deleteReview(reviewId); + + if (review.rating) { + await recomputeVenueRating(venueId); + } +} + +// review.userId is populated with 'username' by repo.findVenueReviews, so it's +// a user sub-document (with _id), not a raw ObjectId — extract the real id. +function extractUserId(userId: unknown): string { + if (userId && typeof userId === 'object' && '_id' in userId) { + return (userId as { _id: { toString(): string } })._id.toString(); + } + return (userId as { toString(): string }).toString(); +} + +export async function getVenueReviews( + venueId: string, + paginationParams: PaginationParams +): Promise> { + const result = await repo.findVenueReviews(venueId, paginationParams); + + // Batch-fetch verified status and reviewer ratings + const userIds = result.reviews.map((r) => extractUserId(r.userId)); + const [verifiedUserIds, reviewerRatings] = await Promise.all([ + bookingRepo.findVerifiedUserIds(venueId, userIds), + repo.findRatingsForUsers(venueId, userIds), + ]); + + // Enrich each review with verified status and reviewer's rating + const enrichedReviews = result.reviews.map((review): IReviewModel => { + const uid = extractUserId(review.userId); + return { + ...review.toObject(), + isVerified: verifiedUserIds.has(uid), + reviewerRating: reviewerRatings.get(uid), + } as IReviewModel; + }); + + return { + ...result, + reviews: enrichedReviews, + }; +} + +export async function getFlaggedReviews( + paginationParams: PaginationParams +): Promise> { + return repo.findFlaggedReviews(paginationParams); +} + +export async function moderateReview( + reviewId: string, + dto: ModerateReviewDTO, + moderatorId: string +): Promise { + // Validate inputs + if (dto.action === 'flag' || dto.action === 'remove' || dto.action === 'approve_hide') { + if (!dto.reason || dto.reason.trim().length === 0) { + throw new ValidationError('Reason is required for flag/remove/approve actions'); + } + if (dto.reason.length < 10) { + throw new ValidationError('Reason must be at least 10 characters'); + } + } + + const review = await repo.findReviewById(reviewId); + if (!review) { + throw new NotFoundError('Review not found'); + } + + const venue = await VenueModel.findById(review.venueId).select('name').lean().exec(); + const venueName = venue?.name ?? 'Unknown Venue'; + + const updated = + dto.action === 'approve_hide' || dto.action === 'reject_hide' + ? await repo.resolveHideRequest( + reviewId, + dto.action === 'approve_hide' ? 'approve' : 'reject', + moderatorId + ) + : await repo.moderateReview(reviewId, dto, moderatorId); + + if (!updated) { + throw new NotFoundError('Review not found'); + } + + // Recompute venue rating if moderation affected visibility + if (dto.action === 'remove' || dto.action === 'restore' || dto.action === 'approve_hide') { + await recomputeVenueRating(updated.venueId.toString()); + } + + // Send email notifications to review author + if (dto.action === 'remove' || dto.action === 'approve_hide') { + try { + await enqueueEmailTask( + updated.userId.toString(), + EmailIntent.REVIEW_REMOVED, + 'Your Review Has Been Removed', + EmailTaskStatus.PENDING, + { venueName, reason: dto.reason ?? 'No reason provided' } + ); + } catch (err) { + logWarn('Failed to queue review removed email', { + module: 'review.service.ts/moderateReview', + reviewId, + error: (err as Error).message, + }); + } + } else if (dto.action === 'restore') { + try { + await enqueueEmailTask( + updated.userId.toString(), + EmailIntent.REVIEW_RESTORED, + 'Your Review Has Been Restored', + EmailTaskStatus.PENDING, + { venueName } + ); + } catch (err) { + logWarn('Failed to queue review restored email', { + module: 'review.service.ts/moderateReview', + reviewId, + error: (err as Error).message, + }); + } + } + + // Log activity + if (dto.action === 'remove' || dto.action === 'restore' || dto.action === 'approve_hide') { + const { logModerationAction } = await import('../moderation/moderationActivity.service.js'); + const actionType = dto.action === 'restore' ? 'restore_review' : 'remove_review'; + await logModerationAction(moderatorId, actionType, reviewId, 'review', dto.reason, { + venueId: updated.venueId.toString(), + userId: updated.userId.toString(), + }); + } + + return updated; +} + +async function recomputeVenueRating(venueId: string): Promise { + try { + const { avgRating, reviewCount } = await repo.getVenueRatingAggregate(venueId); + + await VenueModel.findByIdAndUpdate(venueId, { + avgRating, + reviewCount, + }).exec(); + } catch (err) { + const error = err as Error; + logError('Failed to recompute venue rating', { + module: 'review.service.ts/recomputeVenueRating', + venueId, + error: error.message, + }); + // Non-blocking — continue without throwing + } +} + +export async function getUserReviewedBookings(userId: string): Promise> { + return repo.findUserReviewedBookings(userId); +} + +export async function getOwnerVenueReviews( + venueId: string, + paginationParams: PaginationParams +): Promise> { + return repo.findVenueReviewsForOwner(venueId, paginationParams); +} + +export async function replyToReview( + venueId: string, + reviewId: string, + text: string +): Promise { + const review = await repo.findReviewById(reviewId); + if (!review) throw new NotFoundError('Review not found'); + + const { isBannedForScope } = await import('../moderation/bannedUser.service.js'); + const venue = await VenueModel.findById(venueId); + if (venue) { + const isBanned = await isBannedForScope(venue.ownerUserId.toString(), 'commenting', venueId); + if (isBanned) { + throw new ConflictError('You are currently banned from replying to reviews.'); + } + } + + const updated = await repo.addOwnerReply(reviewId, venueId, text); + if (!updated) { + throw new NotFoundError('Review not found'); + } + return updated; +} + +export async function requestHideForReview( + venueId: string, + reviewId: string, + ownerId: string, + reason: string +): Promise { + const review = await repo.findReviewById(reviewId); + if (!review) { + throw new NotFoundError('Review not found'); + } + + if (review.venueId.toString() !== venueId) { + throw new NotFoundError('Review not found'); + } + + if (review.hideRequestStatus === 'pending') { + throw new ConflictError('A hide request is already pending for this review'); + } + + const updated = await repo.requestHideReview(reviewId, venueId, ownerId, reason); + if (!updated) { + throw new NotFoundError('Review not found'); + } + + return updated; +} + +export async function flagReview(reviewId: string, reason: string): Promise { + const review = await repo.findReviewById(reviewId); + if (!review) { + throw new NotFoundError('Review not found'); + } + + const updated = await repo.flagReview(reviewId, reason); + if (!updated) { + throw new NotFoundError('Review not found'); + } + + // Recompute venue rating since the review is hidden + if (review.rating) { + await recomputeVenueRating(updated.venueId.toString()); + } + + return updated; +} + +export async function getPendingHideRequests( + paginationParams: PaginationParams +): Promise> { + return repo.findPendingHideRequests(paginationParams); +} diff --git a/server/src/modules/review/review.types.ts b/server/src/modules/review/review.types.ts new file mode 100644 index 0000000000..9c103bf813 --- /dev/null +++ b/server/src/modules/review/review.types.ts @@ -0,0 +1,47 @@ +export interface IReview { + _id: string; + venueId: string; + userId: string; + bookingId?: string | null; + rating?: number; + comment?: string; + status: 'visible' | 'flagged' | 'removed'; + moderationReason?: string; + moderatedBy?: string; + moderatedAt?: Date; + editedAt?: Date; + createdAt: Date; + updatedAt: Date; + isVerified?: boolean; + reviewerRating?: number; +} + +export interface CreateReviewDTO { + venueId: string; + rating?: number; + comment?: string; +} + +export interface UpdateReviewDTO { + rating?: number; + comment?: string; +} + +export interface ModerateReviewDTO { + action: 'flag' | 'remove' | 'restore' | 'approve_hide' | 'reject_hide'; + reason?: string; +} + +export interface ReviewWithUserInfo extends IReview { + user?: { + username: string; + }; +} + +export interface OwnerReplyDTO { + text: string; +} + +export interface RequestHideDTO { + reason: string; +} diff --git a/server/src/modules/role/role.controller.ts b/server/src/modules/role/role.controller.ts new file mode 100644 index 0000000000..9b3c70ba31 --- /dev/null +++ b/server/src/modules/role/role.controller.ts @@ -0,0 +1,41 @@ +import type { Request, Response } from 'express'; +import { ResponseUtil } from '../../utils/responseUtils'; +import { handleError } from '../../utils/errors'; +import * as service from './role.service'; + +export const roleController = { + promoteToAdmin: async (req: Request, res: Response): Promise => { + try { + const { email } = req.validated?.body as { email: string }; + await service.promoteToAdmin(email); + ResponseUtil.success(res, 'User promoted to admin successfully'); + } catch (e) { + handleError(res, e, 'promoteToAdmin'); + } + }, + + demoteAdmin: async (req: Request, res: Response): Promise => { + try { + const { userId } = req.validated?.body as { userId: string }; + await service.demoteAdmin(userId); + ResponseUtil.success(res, 'Admin demoted successfully'); + } catch (e) { + handleError(res, e, 'demoteAdmin'); + } + }, + + getAdmins: async (req: Request, res: Response): Promise => { + try { + const paginationParams = req.pagination ?? { + page: 1, + limit: 10, + skip: 0, + sort: '-createdAt', + }; + const result = await service.getAdmins(paginationParams); + ResponseUtil.success(res, 'Admins retrieved successfully', result); + } catch (e) { + handleError(res, e, 'getAdmins'); + } + }, +}; diff --git a/server/src/modules/role/role.repository.ts b/server/src/modules/role/role.repository.ts new file mode 100644 index 0000000000..b81f11fd49 --- /dev/null +++ b/server/src/modules/role/role.repository.ts @@ -0,0 +1,85 @@ +import { UserModel } from '../user/user.models'; +import type { IUser } from '../user/user.models'; +import { RoleModel } from '../../models/role.model'; +import type { IRole } from '../../models/role.model'; +import { UserRoleModel } from '../../models/user-role.model'; +import type { IUserRole } from '../../models/user-role.model'; +import type mongoose from 'mongoose'; +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import { buildPaginationMeta } from '../../utils/paginationUtils'; + +export async function findActiveUserByEmail(email: string): Promise { + return UserModel.findOne({ email, active: true, deleted: false }).exec(); +} + +export async function findUserById(userId: string): Promise { + return UserModel.findById(userId).exec(); +} + +export async function findRoleByName(name: string): Promise { + return RoleModel.findOne({ name }).exec(); +} + +export async function findUserRole( + userId: mongoose.Types.ObjectId, + roleId: mongoose.Types.ObjectId +): Promise { + return UserRoleModel.findOne({ userId, roleId }).exec(); +} + +export async function createUserRole( + userId: mongoose.Types.ObjectId, + roleId: mongoose.Types.ObjectId +): Promise { + return UserRoleModel.create({ userId, roleId }); +} + +export async function updateUserRoleStatus( + existing: IUserRole, + active: boolean, + deleted: boolean +): Promise { + existing.active = active; + existing.deleted = deleted; + await existing.save(); +} + +export async function findAdminsWithPagination( + paginationParams: PaginationParams, + adminRoleId: mongoose.Types.ObjectId +): Promise, 'admins'>> { + const { limit, skip } = paginationParams; + const matchStage = { roleId: adminRoleId, active: true, deleted: false }; + + const [userRoles, totalCountResult] = await Promise.all([ + UserRoleModel.aggregate>([ + { $match: matchStage }, + { $skip: skip }, + { $limit: limit }, + { + $lookup: { + from: 'Users', + localField: 'userId', + foreignField: '_id', + as: 'user', + }, + }, + { $unwind: '$user' }, + { + $project: { + _id: '$user._id', + username: '$user.username', + email: '$user.email', + active: '$user.active', + createdAt: '$user.createdAt', + }, + }, + ]), + UserRoleModel.countDocuments(matchStage), + ]); + + return { + admins: userRoles, + pagination: buildPaginationMeta(totalCountResult, paginationParams), + }; +} diff --git a/server/src/modules/role/role.router.ts b/server/src/modules/role/role.router.ts new file mode 100644 index 0000000000..ae05652d2d --- /dev/null +++ b/server/src/modules/role/role.router.ts @@ -0,0 +1,104 @@ +import { Router } from 'express'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { requireSuperAdmin } from '../../middlewares/rbac.middleware'; +import { validateBody, validateQuery } from '../../middlewares/validation.middleware'; +import { paginationMiddleware } from '../../middlewares/pagination.middleware'; +import * as validator from './role.validator'; +import { roleController } from './role.controller'; + +const router: Router = Router(); + +/** + * @openapi + * /role/promote: + * post: + * tags: [Role] + * summary: Promote a user to admin (super-admin only) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * responses: + * 200: + * description: User promoted + */ +router + .route('/promote') + .post( + verifyAccessToken, + requireSuperAdmin, + validateBody(validator.promoteUserSchema), + roleController.promoteToAdmin + ); + +/** + * @openapi + * /role/demote: + * post: + * tags: [Role] + * summary: Demote an admin (super-admin only) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [userId] + * properties: + * userId: + * type: string + * responses: + * 200: + * description: Admin demoted + */ +router + .route('/demote') + .post( + verifyAccessToken, + requireSuperAdmin, + validateBody(validator.demoteUserSchema), + roleController.demoteAdmin + ); + +/** + * @openapi + * /role/admins: + * get: + * tags: [Role] + * summary: List all admins (super-admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: List of admins + */ +router + .route('/admins') + .get( + verifyAccessToken, + requireSuperAdmin, + validateQuery(validator.getAdminsQuerySchema), + paginationMiddleware(), + roleController.getAdmins + ); + +export { router as roleRouter }; diff --git a/server/src/modules/role/role.service.ts b/server/src/modules/role/role.service.ts new file mode 100644 index 0000000000..03b6b68f15 --- /dev/null +++ b/server/src/modules/role/role.service.ts @@ -0,0 +1,44 @@ +import { NotFoundError, ConflictError } from '../../utils/errors'; +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import * as repo from './role.repository'; + +export async function promoteToAdmin(email: string): Promise { + const user = await repo.findActiveUserByEmail(email); + if (!user) throw new NotFoundError('User not found'); + + const adminRole = await repo.findRoleByName('admin'); + if (!adminRole) throw new Error('Admin role not found in system'); + + const existing = await repo.findUserRole(user._id, adminRole._id); + if (existing) { + if (existing.active && !existing.deleted) throw new ConflictError('User is already an admin'); + await repo.updateUserRoleStatus(existing, true, false); + return; + } + + await repo.createUserRole(user._id, adminRole._id); +} + +export async function demoteAdmin(userId: string): Promise { + const user = await repo.findUserById(userId); + if (!user) throw new NotFoundError('User not found'); + + const adminRole = await repo.findRoleByName('admin'); + if (!adminRole) throw new Error('Admin role not found in system'); + + const existing = await repo.findUserRole(user._id, adminRole._id); + if (!existing || !existing.active || existing.deleted) { + throw new ConflictError('User is not an active admin'); + } + + await repo.updateUserRoleStatus(existing, false, true); +} + +export async function getAdmins( + paginationParams: PaginationParams +): Promise, 'admins'>> { + const adminRole = await repo.findRoleByName('admin'); + if (!adminRole) throw new Error('Admin role not found in system'); + + return repo.findAdminsWithPagination(paginationParams, adminRole._id); +} diff --git a/server/src/modules/role/role.validator.ts b/server/src/modules/role/role.validator.ts new file mode 100644 index 0000000000..f8504aba23 --- /dev/null +++ b/server/src/modules/role/role.validator.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const promoteUserSchema = z.object({ + email: z.email('Invalid email format'), +}); + +export const demoteUserSchema = z.object({ + userId: z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid user ID format'), +}); + +export const getAdminsQuerySchema = z.object({ + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(10), +}); diff --git a/server/src/modules/swagger/swagger.config.ts b/server/src/modules/swagger/swagger.config.ts new file mode 100644 index 0000000000..68ee8e0329 --- /dev/null +++ b/server/src/modules/swagger/swagger.config.ts @@ -0,0 +1,92 @@ +import swaggerJsdoc from 'swagger-jsdoc'; +import type { OAS3Definition } from 'swagger-jsdoc'; +import path from 'path'; + +const definition: OAS3Definition = { + openapi: '3.0.3', + info: { + title: 'BookMyVenue API', + version: '1.0.0', + description: + 'REST API for the BookMyVenue platform — venue discovery, slot locking, and Razorpay-backed booking flow.', + }, + servers: [ + { + url: '/api/v1', + description: 'Development server (localhost)', + }, + { + url: 'https://bmvserver.shares.zrok.io/api/v1', + description: 'zrok tunnel (HTTPS)', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Access token returned by /auth/login or /auth/refresh', + }, + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'refreshToken', + description: 'Refresh token cookie used by /auth/refresh and /auth/logout', + }, + }, + schemas: { + SuccessResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + message: { type: 'string' }, + data: { type: 'object' }, + }, + }, + ErrorResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + message: { type: 'string' }, + }, + }, + PaginatedMeta: { + type: 'object', + properties: { + page: { type: 'integer' }, + limit: { type: 'integer' }, + total: { type: 'integer' }, + totalPages: { type: 'integer' }, + }, + }, + }, + }, + tags: [ + { name: 'Health', description: 'Server health check' }, + { name: 'Auth', description: 'Authentication and session management' }, + { name: 'User', description: 'Authenticated user profile' }, + { name: 'Venues', description: 'Venue management — public, owner, and admin operations' }, + { name: 'Availability', description: 'Venue availability and slot locking' }, + { name: 'Bookings', description: 'Checkout and payment flow' }, + { name: 'RBAC', description: 'Permission cache management (super-admin only)' }, + { name: 'Webhooks', description: 'Incoming Razorpay payment event handler' }, + { name: 'Geo', description: 'Geospatial lookups (city/state autocomplete)' }, + { name: 'Moderation', description: 'User moderation — ban and unban operations (admin only)' }, + { name: 'Owner', description: 'Owner onboarding, earnings, and dashboard' }, + { name: 'Reviews', description: 'Venue review submission and moderation' }, + { name: 'Role', description: 'Role definitions and assignment (admin only)' }, + { name: 'Wishlist', description: 'User venue wishlist management' }, + ], +}; + +const options: swaggerJsdoc.Options = { + definition, + apis: [ + path.join(process.cwd(), 'src/modules/**/*.routes.ts').replace(/\\/g, '/'), + path.join(process.cwd(), 'src/modules/**/*.router.ts').replace(/\\/g, '/'), + path.join(process.cwd(), 'src/router.ts').replace(/\\/g, '/'), + ], +}; + +export const openapiDocument = swaggerJsdoc(options); diff --git a/server/src/modules/swagger/swagger.router.ts b/server/src/modules/swagger/swagger.router.ts new file mode 100644 index 0000000000..ee4c34c2b8 --- /dev/null +++ b/server/src/modules/swagger/swagger.router.ts @@ -0,0 +1,81 @@ +import crypto from 'crypto'; +import { Router } from 'express'; +import type { Request, Response, NextFunction } from 'express'; +import swaggerUi from 'swagger-ui-express'; +import { openapiDocument } from './swagger.config'; +import { swaggerConfig, nodeEnv } from '../../constants/env'; + +const router: Router = Router(); + +// Only serve Swagger UI in development +function devOnly(_req: Request, res: Response, next: NextFunction): void { + if (nodeEnv !== 'development') { + res.status(404).json({ message: 'Not found' }); + return; + } + next(); +} + +// Basic Auth guard for the Swagger UI +function basicAuth(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Basic ')) { + res.setHeader('WWW-Authenticate', 'Basic realm="Swagger UI"'); + res.status(401).json({ message: 'Authentication required' }); + return; + } + + // Reject if credentials are not configured + if (!swaggerConfig.user || !swaggerConfig.pass) { + res.setHeader('WWW-Authenticate', 'Basic realm="Swagger UI"'); + res.status(401).json({ message: 'Invalid credentials' }); + return; + } + + const base64 = authHeader.slice('Basic '.length); + const decoded = Buffer.from(base64, 'base64').toString('utf8'); + const [user, ...rest] = decoded.split(':'); + const pass = rest.join(':'); + + // Hash both sides to a fixed-length digest before comparing. + // crypto.timingSafeEqual throws a RangeError when buffer lengths differ, + // which happens on virtually every wrong-password attempt. HMAC-SHA256 + // normalises both operands to 32 bytes while preserving timing safety. + const HMAC_KEY = Buffer.from('swagger-basic-auth-comparison-key'); + const hash = (s: string): Buffer => crypto.createHmac('sha256', HMAC_KEY).update(s).digest(); + + const userMatch = crypto.timingSafeEqual(hash(user), hash(swaggerConfig.user)); + const passMatch = crypto.timingSafeEqual(hash(pass), hash(swaggerConfig.pass)); + + if (!userMatch || !passMatch) { + res.setHeader('WWW-Authenticate', 'Basic realm="Swagger UI"'); + res.status(401).json({ message: 'Invalid credentials' }); + return; + } + + next(); +} + +// Expose the raw OpenAPI JSON for Postman / import +router.get('/json', devOnly, basicAuth, (_req, res) => { + res.json(openapiDocument); +}); + +// Mount the Swagger UI +router.use('/', devOnly, basicAuth, swaggerUi.serve); +router.get( + '/', + devOnly, + basicAuth, + swaggerUi.setup(openapiDocument, { + customSiteTitle: 'BookMyVenue API Docs', + swaggerOptions: { + persistAuthorization: true, + filter: true, + displayRequestDuration: true, + }, + }) +); + +export { router as swaggerRouter }; diff --git a/server/src/modules/user/user.controller.ts b/server/src/modules/user/user.controller.ts new file mode 100644 index 0000000000..92e4e4bb2f --- /dev/null +++ b/server/src/modules/user/user.controller.ts @@ -0,0 +1,162 @@ +import type { Request, Response } from 'express'; +import type { z } from 'zod'; +import { ResponseUtil } from '../../utils/responseUtils'; +import * as service from './user.service'; +import * as workflow from './user.workflow'; +import { handleError } from '../../utils/errors'; +import type { updateProfileSchema } from './user.validator'; +import type { BanUserRequest } from './user.types'; + +export const getProfile = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const profile = await service.getProfile(userId); + ResponseUtil.success(res, 'User profile retrieved successfully', profile); + } catch (e) { + handleError(res, e, 'getProfile'); + } +}; + +export const updateProfile = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const dto = req.validated?.body as z.infer; + const profile = await service.updateProfile(userId, dto); + ResponseUtil.success(res, 'Profile updated successfully', profile); + } catch (e) { + handleError(res, e, 'updateProfile'); + } +}; + +export const deleteProfilePicture = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const profile = await service.deleteProfilePicture(userId); + ResponseUtil.success(res, 'Profile picture removed successfully', profile); + } catch (e) { + handleError(res, e, 'deleteProfilePicture'); + } +}; + +export const getAvatarUploadSignature = (req: Request, res: Response): void => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const signed = service.getAvatarUploadSignature(userId); + if (!signed) { + ResponseUtil.internalServerError(res, 'Image upload not configured'); + return; + } + + ResponseUtil.success(res, 'Signature generated', signed); + } catch (e) { + handleError(res, e, 'getAvatarUploadSignature'); + } +}; + +export const getAllUsers = async (req: Request, res: Response): Promise => { + try { + const paginationParams = req.pagination ?? { page: 1, limit: 10, skip: 0, sort: '-createdAt' }; + const role = req.query.role as string | undefined; + + const result = await service.getAllUsers(paginationParams, { role }); + ResponseUtil.success(res, 'Users retrieved successfully', result); + } catch (e) { + handleError(res, e, 'getAllUsers'); + } +}; + +export const toggleUserStatus = async (req: Request, res: Response): Promise => { + try { + const { userId } = req.params; + + // Prevent modifying own status + if (req.user?.userId === userId) { + ResponseUtil.badRequest(res, 'You cannot toggle your own status'); + return; + } + const user = await service.toggleUserStatus(userId as string); + if (!user) { + ResponseUtil.notFound(res, 'User not found'); + return; + } + + ResponseUtil.success(res, `User is now ${user.active ? 'active' : 'inactive'}`, { + active: user.active, + }); + } catch (e) { + handleError(res, e, 'toggleUserStatus'); + } +}; + +export const resetUserPassword = async (req: Request, res: Response): Promise => { + try { + const { userId } = req.params; + await workflow.resetUserPasswordWorkflow(userId as string); + + ResponseUtil.success(res, 'Password reset successfully, email dispatched'); + } catch (e) { + const error = e as Error; + if (error.message.includes('not found')) { + ResponseUtil.notFound(res, error.message); + } else { + handleError(res, e, 'resetUserPassword'); + } + } +}; + +export const banUser = async (req: Request, res: Response): Promise => { + try { + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const userId = Array.isArray(req.params.userId) ? req.params.userId[0] : req.params.userId; + const { banReason } = req.body as BanUserRequest; + + if (!banReason || banReason.trim().length < 10) { + ResponseUtil.badRequest(res, 'Ban reason must be at least 10 characters'); + return; + } + + const user = await service.banUser(userId, adminId, banReason); + ResponseUtil.success(res, 'User banned successfully', { + username: user.username, + isBanned: user.isBanned, + }); + } catch (e) { + handleError(res, e, 'banUser'); + } +}; + +export const unbanUser = async (req: Request, res: Response): Promise => { + try { + const userId = Array.isArray(req.params.userId) ? req.params.userId[0] : req.params.userId; + const user = await service.unbanUser(userId); + ResponseUtil.success(res, 'User unbanned successfully', { username: user.username }); + } catch (e) { + handleError(res, e, 'unbanUser'); + } +}; diff --git a/server/src/modules/user/user.models.ts b/server/src/modules/user/user.models.ts new file mode 100644 index 0000000000..953b842836 --- /dev/null +++ b/server/src/modules/user/user.models.ts @@ -0,0 +1,39 @@ +import type { Document } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +export interface IUser extends Document { + username: string; + email: string; + password: string; + createdAt: Date; + active: boolean; + deleted: boolean; + passwordChangedAt?: Date | null; + isBanned: boolean; + profilePicture?: string; + profilePicturePublicId?: string; +} + +const UserSchema = new Schema( + { + username: { type: String, required: true, unique: true }, + email: { type: String, required: true }, + password: { type: String, required: true, select: false }, + active: { type: Boolean, default: true }, + deleted: { type: Boolean, default: false }, + passwordChangedAt: { type: Date, default: null }, + isBanned: { type: Boolean, default: false, index: true }, + profilePicture: { type: String }, + profilePicturePublicId: { type: String }, + }, + { timestamps: true } +); + +UserSchema.index( + { email: 1 }, + { unique: true, sparse: true, partialFilterExpression: { active: true } } +); + +UserSchema.index({ active: 1, deleted: 1, isBanned: 1 }, { name: 'idx_active_deleted_banned' }); + +export const UserModel = mongoose.model('Users', UserSchema, 'Users'); diff --git a/server/src/modules/user/user.repository.ts b/server/src/modules/user/user.repository.ts new file mode 100644 index 0000000000..74ec312529 --- /dev/null +++ b/server/src/modules/user/user.repository.ts @@ -0,0 +1,226 @@ +import type { IUser } from './user.models'; +import { UserModel } from './user.models'; +import type mongoose from 'mongoose'; +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import { buildPaginationMeta } from '../../utils/paginationUtils'; + +export async function findUserById(userId: string): Promise { + return UserModel.findById(userId).select('-password').exec(); +} + +export async function findUserEmailById(userId: string): Promise { + const user = await UserModel.findById(userId).select('email').lean().exec(); + return (user as { email?: string } | null)?.email ?? null; +} + +export async function findActiveUserById(id: string): Promise { + return UserModel.findOne({ _id: id, active: true, deleted: false }).exec(); +} + +export async function findActiveUserByIdWithPassword( + id: string, + session?: mongoose.ClientSession +): Promise { + return UserModel.findOne({ _id: id, active: true, deleted: false }) + .select('+password') + .session(session ?? null) + .exec(); +} + +export async function findUserByUsernameOrEmail( + username?: string, + email?: string +): Promise { + return UserModel.findOne({ $or: [{ username }, { email }] }).exec(); +} + +export async function findActiveUserByIdentifierWithPassword( + identifier: string +): Promise { + return UserModel.findOne({ + $or: [{ username: identifier }, { email: identifier }], + active: true, + deleted: false, + }) + .select('+password') + .exec(); +} + +export async function findActiveUserByEmail(email: string): Promise { + return UserModel.findOne({ email, active: true, deleted: false }).exec(); +} + +export async function updateUserPassword( + userId: string | mongoose.Types.ObjectId, + passwordHash: string, + session?: mongoose.ClientSession +): Promise { + await UserModel.updateOne( + { _id: userId }, + { $set: { password: passwordHash, passwordChangedAt: new Date() } }, + { session } + ).exec(); +} + +export async function findActiveUserByUsernameExcludingId( + username: string, + excludeUserId: string +): Promise { + return UserModel.findOne({ + username, + _id: { $ne: excludeUserId }, + deleted: false, + }).exec(); +} + +export async function updateUserProfile( + userId: string, + data: { username?: string; profilePicture?: string; profilePicturePublicId?: string } +): Promise { + return UserModel.findByIdAndUpdate(userId, { $set: data }, { new: true }).exec(); +} + +export async function clearUserProfilePicture(userId: string): Promise { + return UserModel.findByIdAndUpdate( + userId, + { $unset: { profilePicture: '', profilePicturePublicId: '' } }, + { new: true } + ).exec(); +} + +export async function createUser( + data: { username: string; email: string; passwordHash: string }, + session?: mongoose.ClientSession +): Promise { + const newUser = new UserModel({ + username: data.username, + email: data.email, + password: data.passwordHash, + }); + return newUser.save({ session }); +} + +export async function findAllUsers( + paginationParams: PaginationParams, + filters?: { role?: string } +): Promise, 'users'>> { + const { limit, skip } = paginationParams; + const matchStage: Record = { deleted: false }; + + const pipeline: mongoose.PipelineStage[] = [ + { $match: matchStage }, + { + $lookup: { + from: 'UserRoles', + localField: '_id', + foreignField: 'userId', + as: 'userRoles', + }, + }, + { + $lookup: { + from: 'Roles', + localField: 'userRoles.roleId', + foreignField: '_id', + as: 'roles', + }, + }, + { + $lookup: { + from: 'Venues', + localField: '_id', + foreignField: 'ownerUserId', + as: 'venues', + }, + }, + ]; + + if (filters?.role) { + pipeline.push({ + $match: { 'roles.name': filters.role }, + }); + } + + const [users, totalCountResult] = await Promise.all([ + UserModel.aggregate>([ + ...pipeline, + { $sort: { createdAt: -1 } }, + { $skip: skip }, + { $limit: limit }, + { + $project: { + _id: 1, + username: 1, + email: 1, + active: 1, + isBanned: 1, + createdAt: 1, + roles: { $map: { input: '$roles', as: 'r', in: '$$r.name' } }, + venues: 1, + }, + }, + ]), + UserModel.aggregate>([...pipeline, { $count: 'total' }]), + ]); + + const totalCount = totalCountResult[0]?.total ?? 0; + + return { + users, + pagination: buildPaginationMeta(totalCount as number, paginationParams), + }; +} + +export async function toggleUserStatus(userId: string): Promise { + const user = await UserModel.findById(userId); + if (!user) { + return null; + } + user.active = !user.active; + await user.save(); + return user; +} + +export async function banUser(userId: string, adminId: string, banReason: string): Promise { + const user = await UserModel.findByIdAndUpdate( + userId, + { + active: false, + banReason, + bannedBy: adminId, + bannedAt: new Date(), + }, + { new: true } + ).exec(); + + if (!user) { + throw new Error('User not found'); + } + + return user; +} + +export async function unbanUser(userId: string): Promise { + // Using $unset for legacy fields, and $set for actual schema fields + const user = await UserModel.findByIdAndUpdate( + userId, + { + $unset: { + banReason: 1, + bannedBy: 1, + bannedAt: 1, + }, + $set: { + isBanned: false, + active: true, + }, + }, + { new: true, strict: false } + ).exec(); + + if (!user) { + throw new Error('User not found'); + } + + return user; +} diff --git a/server/src/modules/user/user.router.ts b/server/src/modules/user/user.router.ts new file mode 100644 index 0000000000..db0679a284 --- /dev/null +++ b/server/src/modules/user/user.router.ts @@ -0,0 +1,258 @@ +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import * as controller from './user.controller'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { requireRole } from '../../middlewares/rbac.middleware'; +import { validateBody } from '../../middlewares/validation.middleware'; +import { paginationMiddleware } from '../../middlewares/pagination.middleware'; +import * as validator from './user.validator'; + +const router: Router = Router(); + +const uploadSignatureLimiter = rateLimit({ + windowMs: 30 * 60 * 1000, + max: 20, + message: 'Too many upload requests, please try again later', +}); + +/** + * @openapi + * /user/profile: + * get: + * tags: [User] + * summary: Get the authenticated user's profile + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: User profile data + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * data: + * type: object + * properties: + * _id: + * type: string + * username: + * type: string + * email: + * type: string + * 401: + * description: Not authenticated + */ +router.route('/profile').get(verifyAccessToken, controller.getProfile); + +/** + * @openapi + * /user/profile: + * patch: + * tags: [User] + * summary: Update the authenticated user's own profile (username and/or profile picture) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * profilePicture: + * type: string + * profilePicturePublicId: + * type: string + * responses: + * 200: + * description: Profile updated successfully + * 400: + * description: Validation error + * 401: + * description: Not authenticated + * 409: + * description: Username already taken + */ +router + .route('/profile') + .patch(verifyAccessToken, validateBody(validator.updateProfileSchema), controller.updateProfile); + +/** + * @openapi + * /user/profile/upload-signature: + * get: + * tags: [User] + * summary: Get a Cloudinary upload signature for a profile picture upload + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Cloudinary signature and upload parameters + * 401: + * description: Not authenticated + * 429: + * description: Too many upload signature requests + */ +router + .route('/profile/upload-signature') + .get(verifyAccessToken, uploadSignatureLimiter, controller.getAvatarUploadSignature); + +/** + * @openapi + * /user/profile/picture: + * delete: + * tags: [User] + * summary: Remove the authenticated user's profile picture + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Profile picture removed successfully + * 400: + * description: No profile picture to remove + * 401: + * description: Not authenticated + */ +router.route('/profile/picture').delete(verifyAccessToken, controller.deleteProfilePicture); + +/** + * @openapi + * /user/all: + * get: + * tags: [User] + * summary: Get all users (Admin/SuperAdmin) + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: role + * schema: + * type: string + * description: Filter users by role name + * responses: + * 200: + * description: List of users + * 401: + * description: Not authenticated + * 403: + * description: Forbidden + */ +router + .route('/all') + .get(verifyAccessToken, requireRole('admin'), paginationMiddleware(), controller.getAllUsers); + +/** + * @openapi + * /user/{userId}/toggle-status: + * patch: + * tags: [User] + * summary: Toggle user active status (SuperAdmin) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: User status toggled successfully + * 401: + * description: Not authenticated + * 403: + * description: SuperAdmin role required + */ +router + .route('/:userId/toggle-status') + .patch(verifyAccessToken, requireRole('superAdmin'), controller.toggleUserStatus); + +/** + * @openapi + * /user/{userId}/reset-password: + * post: + * tags: [User] + * summary: Reset user password and email (SuperAdmin) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: User password reset successfully + * 401: + * description: Not authenticated + * 403: + * description: SuperAdmin role required + */ +router + .route('/:userId/reset-password') + .post(verifyAccessToken, requireRole('superAdmin'), controller.resetUserPassword); + +/** + * @openapi + * /user/{userId}/ban: + * post: + * tags: [User] + * summary: Ban a user (Admin) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * banReason: + * type: string + * responses: + * 200: + * description: User banned successfully + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + */ +router.route('/:userId/ban').post(verifyAccessToken, requireRole('admin'), controller.banUser); + +/** + * @openapi + * /user/{userId}/unban: + * post: + * tags: [User] + * summary: Unban a user (Admin) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: User unbanned successfully + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + */ +router.route('/:userId/unban').post(verifyAccessToken, requireRole('admin'), controller.unbanUser); + +export { router as userRouter }; diff --git a/server/src/modules/user/user.service.ts b/server/src/modules/user/user.service.ts new file mode 100644 index 0000000000..5bb6a40b2f --- /dev/null +++ b/server/src/modules/user/user.service.ts @@ -0,0 +1,194 @@ +import crypto from 'crypto'; +import { v2 as cloudinary } from 'cloudinary'; +import * as repo from './user.repository'; +import { NotFoundError, ForbiddenError, ConflictError, ValidationError } from '../../utils/errors'; +import type { IUser } from './user.models'; +import { getUserRole } from '../../services/roles.service'; +import type { PaginatedResponse, PaginationParams } from '../../types/pagination.types'; +import { signUploadParams, type CloudinarySignature } from '../../utils/cloudinarySign'; +import { logError } from '../../utils/logger'; + +interface ProfileDto { + _id: string; + name: string; + email: string; + profilePicture?: string; +} + +function toProfileDto(user: IUser): ProfileDto { + return { + _id: user._id.toString(), + name: user.username, + email: user.email, + profilePicture: user.profilePicture, + }; +} + +export async function getProfile(userId: string): Promise { + const user = await repo.findUserById(userId); + + if (!user) { + throw new NotFoundError('User not found'); + } + + const roleInfo = await getUserRole(userId); + + return { ...toProfileDto(user), role: roleInfo?.roleName }; +} + +export async function updateProfile( + userId: string, + dto: { username?: string; profilePicturePublicId?: string } +): Promise { + const existingUser = await repo.findUserById(userId); + if (!existingUser) { + throw new NotFoundError('User not found'); + } + + if (dto.username && dto.username !== existingUser.username) { + const usernameTaken = await repo.findActiveUserByUsernameExcludingId(dto.username, userId); + if (usernameTaken) { + throw new ConflictError('Username is already taken'); + } + } + + const updateData: { + username?: string; + profilePicture?: string; + profilePicturePublicId?: string; + } = {}; + if (dto.username) { + updateData.username = dto.username; + } + + if (dto.profilePicturePublicId) { + const ownFolderPrefix = `bookmyvenue/users/${userId}/`; + if (!dto.profilePicturePublicId.startsWith(ownFolderPrefix)) { + throw new ForbiddenError('Profile picture must be one you uploaded'); + } + + let verifiedUrl: string; + try { + const resource = (await cloudinary.api.resource(dto.profilePicturePublicId)) as { + secure_url: string; + }; + verifiedUrl = resource.secure_url; + } catch { + throw new ValidationError('Could not verify the uploaded profile picture'); + } + + if ( + existingUser.profilePicturePublicId && + existingUser.profilePicturePublicId !== dto.profilePicturePublicId + ) { + try { + await cloudinary.uploader.destroy(existingUser.profilePicturePublicId); + } catch (e) { + const error = e as Error; + logError('Failed to delete old profile picture from Cloudinary', { + module: 'user.service.ts/updateProfile', + userId, + publicId: existingUser.profilePicturePublicId, + error: error.message, + }); + } + } + + updateData.profilePicture = verifiedUrl; + updateData.profilePicturePublicId = dto.profilePicturePublicId; + } + + const updated = await repo.updateUserProfile(userId, updateData); + if (!updated) { + throw new NotFoundError('User not found'); + } + + return toProfileDto(updated); +} + +export async function deleteProfilePicture(userId: string): Promise { + const existingUser = await repo.findUserById(userId); + if (!existingUser) { + throw new NotFoundError('User not found'); + } + + if (!existingUser.profilePicturePublicId) { + throw new ValidationError('No profile picture to remove'); + } + + try { + await cloudinary.uploader.destroy(existingUser.profilePicturePublicId); + } catch (e) { + const error = e as Error; + logError('Failed to delete profile picture from Cloudinary', { + module: 'user.service.ts/deleteProfilePicture', + userId, + publicId: existingUser.profilePicturePublicId, + error: error.message, + }); + } + + const updated = await repo.clearUserProfilePicture(userId); + if (!updated) { + throw new NotFoundError('User not found'); + } + + return toProfileDto(updated); +} + +export function getAvatarUploadSignature(userId: string): CloudinarySignature | null { + return signUploadParams(`bookmyvenue/users/${userId}`); +} + +export async function getAllUsers( + paginationParams: PaginationParams, + filters?: { role?: string } +): Promise, 'users'>> { + return repo.findAllUsers(paginationParams, filters); +} + +export async function toggleUserStatus(userId: string): Promise { + return repo.toggleUserStatus(userId); +} + +export async function generateRandomPasswordWithHash(): Promise<{ plain: string; hashed: string }> { + const bcrypt = await import('bcrypt'); + const newPassword = crypto.randomBytes(12).toString('base64url'); + const hashedPassword = await bcrypt.hash(newPassword, 12); + return { plain: newPassword, hashed: hashedPassword }; +} + +export async function banUser(userId: string, adminId: string, banReason: string): Promise { + // Prevent self-ban + if (userId === adminId) { + throw new ForbiddenError('You cannot ban yourself'); + } + + // Get the user to be banned + const userToban = await repo.findUserById(userId); + if (!userToban) { + throw new NotFoundError('User not found'); + } + + // Prevent banning a superAdmin + const userRole = await getUserRole(userId); + if (userRole?.roleName === 'superAdmin') { + throw new ForbiddenError('You cannot ban a super admin'); + } + + // Ban the user + return repo.banUser(userId, adminId, banReason); +} + +export async function unbanUser(userId: string): Promise { + const user = await repo.findUserById(userId); + if (!user) { + throw new NotFoundError('User not found'); + } + + // Also lift any BannedUsers records to keep systems in sync + const bannedUserRepo = await import('../moderation/bannedUser.repository.js'); + await bannedUserRepo.liftAllBansForUser(userId, userId); // using userId as a dummy liftedBy + + return repo.unbanUser(userId); +} diff --git a/server/src/modules/user/user.types.ts b/server/src/modules/user/user.types.ts new file mode 100644 index 0000000000..b6fa8c5977 --- /dev/null +++ b/server/src/modules/user/user.types.ts @@ -0,0 +1,3 @@ +export interface BanUserRequest { + banReason: string; +} diff --git a/server/src/modules/user/user.validator.ts b/server/src/modules/user/user.validator.ts new file mode 100644 index 0000000000..70ad9d6c60 --- /dev/null +++ b/server/src/modules/user/user.validator.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const updateProfileSchema = z + .object({ + username: z.string().trim().min(3).max(30).optional(), + profilePicturePublicId: z.string().trim().min(1).optional(), + }) + .refine((data) => data.username ?? data.profilePicturePublicId, { + message: 'Nothing to update', + }); diff --git a/server/src/modules/user/user.workflow.ts b/server/src/modules/user/user.workflow.ts new file mode 100644 index 0000000000..e503f2aef9 --- /dev/null +++ b/server/src/modules/user/user.workflow.ts @@ -0,0 +1,26 @@ +import * as repo from './user.repository'; +import * as service from './user.service'; +import { enqueueEmailTask } from '../../services/email.repository'; +import { EmailIntent, EmailTaskStatus } from '../../constants/email.constants'; + +export async function resetUserPasswordWorkflow(userId: string): Promise { + const user = await repo.findUserById(userId); + if (!user) { + throw new Error('User not found'); + } + + const { plain: newPassword, hashed: hashedPassword } = + await service.generateRandomPasswordWithHash(); + + await repo.updateUserPassword(userId, hashedPassword); + + await enqueueEmailTask( + user.email, + EmailIntent.ADMIN_PASSWORD_RESET, + `Password Reset – ${user.username}`, + EmailTaskStatus.PENDING, + { newPassword, username: user.username } + ); + + return true; +} diff --git a/server/src/modules/venue/venue.controller.ts b/server/src/modules/venue/venue.controller.ts new file mode 100644 index 0000000000..dd2ec3010a --- /dev/null +++ b/server/src/modules/venue/venue.controller.ts @@ -0,0 +1,462 @@ +import type { z } from 'zod'; +import type { Request, Response } from 'express'; +import { ResponseUtil } from '../../utils/responseUtils'; +import * as service from './venue.service'; +import * as wishlistService from '../wishlist/wishlist.service'; +import type { + createVenueSchema, + updateVenueSchema, + rejectVenueSchema, + suspendVenueSchema, + adminVenueFiltersSchema, + venueIdParamSchema, + publicVenueFiltersSchema, + featureVenueSchema, + extendVenueDeadlineSchema, + approveReviewSchema, + rejectReviewSchema, +} from './venue.validator'; +import type { PlainVenue } from './venue.types'; +import { handleError } from '../../utils/errors'; +import { signUploadParams } from '../../utils/cloudinarySign'; +import { PERMISSIONS } from '../../constants/permissions'; + +// Helpers +function isCallerPrivileged(req: Request): boolean { + return ( + req.user?.role.isSuperAdmin === true || + req.user?.role.permissions?.has(PERMISSIONS.venues.read) === true + ); +} +// function isCallerAdmin(req: Request): boolean { +// return ( +// req.user?.role.isSuperAdmin === true || +// req.user?.role.permissions?.has('approve:venues') === true +// ); +// } + +export const getUploadSignature = (req: Request, res: Response): void => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const signed = signUploadParams(`bookmyvenue/venues/${userId}`); + if (!signed) { + ResponseUtil.internalServerError(res, 'Image upload not configured'); + return; + } + + ResponseUtil.success(res, 'Signature generated', signed); + } catch (e) { + handleError(res, e, 'getUploadSignature'); + } +}; + +export const getPaginatedActiveVenues = async (req: Request, res: Response): Promise => { + try { + const filters = req.validated?.query as z.infer; + const paginationParams = req.pagination ?? { page: 1, limit: 20, skip: 0, sort: '' }; + const venuesData = await service.getPaginatedActiveVenues(paginationParams, filters); + + // Add wishlisted flag for authenticated users (bulk check, not N+1) + let finalVenues: (PlainVenue & { wishlisted?: boolean })[]; + if (req.user?.userId) { + const venueIds = venuesData.venues.map((v) => v._id.toString()); + const wishlistedStatuses = await wishlistService.getWishlistStatus(req.user.userId, venueIds); + finalVenues = venuesData.venues.map((venue) => ({ + ...(venue.toObject() as PlainVenue), + wishlisted: wishlistedStatuses[venue._id.toString()] ?? false, + })); + } else { + finalVenues = venuesData.venues.map((venue) => ({ + ...(venue.toObject() as PlainVenue), + wishlisted: false, + })); + } + + ResponseUtil.paginated( + res, + 'Venues retrieved successfully', + finalVenues, + venuesData.pagination, + 'venues' + ); + } catch (e) { + handleError(res, e, 'getPaginatedActiveVenues'); + } +}; + +export const getVenuePins = async (req: Request, res: Response): Promise => { + try { + const { swLng, swLat, neLng, neLat } = req.query; + + // Parse and validate bbox parameters + const bbox = + swLng && swLat && neLng && neLat + ? { + swLng: Number(swLng), + swLat: Number(swLat), + neLng: Number(neLng), + neLat: Number(neLat), + } + : undefined; + + const pins = await service.getVenuePins(bbox); + ResponseUtil.success(res, 'Venue pins retrieved successfully', pins); + } catch (e) { + handleError(res, e, 'getVenuePins'); + } +}; + +// Owner Handlers +export const createVenue = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const dto = req.validated?.body as z.infer; + const venue = await service.createVenue(userId, dto); + ResponseUtil.created(res, 'Venue created successfully', venue); + } catch (e) { + handleError(res, e, 'createVenue'); + } +}; + +export const getMyVenues = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const venues = await service.getMyVenues(userId); + ResponseUtil.success(res, 'Venues retrieved successfully', { + count: venues.length, + venues, + }); + } catch (e) { + handleError(res, e, 'getMyVenues'); + } +}; + +// PUT /venues/draft +export const upsertDraft = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const { step, formValues } = req.body as { step: number; formValues: Record }; + if (typeof step !== 'number') { + ResponseUtil.badRequest(res, 'Invalid draft payload'); + return; + } + + const draft = await service.upsertDraft(userId, step, formValues); + ResponseUtil.success(res, 'Draft saved', draft); + } catch (e) { + handleError(res, e, 'upsertDraft'); + } +}; + +// GET /venues/draft +export const getMyDraft = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const draft = await service.getDraft(userId); + ResponseUtil.success(res, 'Draft retrieved', draft); + } catch (e) { + handleError(res, e, 'getMyDraft'); + } +}; + +export const getVenueById = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const venue = await service.getVenueById(id, req.user?.userId, isCallerPrivileged(req)); + + // Add wishlisted flag if user is authenticated + const venueWithWishlist = { + ...(venue.toObject() as PlainVenue), + wishlisted: false, + }; + + if (req.user?.userId) { + const statuses = await wishlistService.getWishlistStatus(req.user.userId, [id]); + venueWithWishlist.wishlisted = statuses[id] ?? false; + } + + ResponseUtil.success(res, 'Venue retrieved successfully', venueWithWishlist); + } catch (e) { + handleError(res, e, 'getVenueById'); + } +}; + +export const updateVenue = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const dto = req.validated?.body as z.infer; + const venue = await service.updateVenue(id, userId, dto); + const requiresReview = venue.pendingReview?.intent === 'venue_edit'; + const msg = requiresReview + ? 'Changes saved. Critical updates submitted for admin review.' + : 'Venue updated successfully'; + ResponseUtil.success(res, msg, venue); + } catch (e) { + handleError(res, e, 'updateVenue'); + } +}; + +export const deleteVenue = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + await service.deleteVenue(id, userId); + ResponseUtil.success(res, 'Venue deleted successfully'); + } catch (e) { + handleError(res, e, 'deleteVenue'); + } +}; + +export const submitVenue = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const venue = await service.submitVenue(id, userId); + ResponseUtil.success(res, 'Venue submitted for review successfully', venue); + } catch (e) { + handleError(res, e, 'submitVenue'); + } +}; + +// Admin Handlers +export const getPendingVenues = async (_req: Request, res: Response): Promise => { + try { + const venues = await service.getPendingVenues(); + ResponseUtil.success(res, 'Pending venues retrieved successfully', { + count: venues.length, + venues, + }); + } catch (e) { + handleError(res, e, 'getPendingVenues'); + } +}; + +export const getAllVenues = async (req: Request, res: Response): Promise => { + try { + const filters = req.validated?.query as z.infer; + const result = await service.getAllVenues(filters); + ResponseUtil.success(res, 'Venues retrieved successfully', result); + } catch (e) { + handleError(res, e, 'getAllVenues'); + } +}; + +export const approveVenue = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const venue = await service.approveVenue(id, adminId); + ResponseUtil.success(res, 'Venue approved successfully', venue); + } catch (e) { + handleError(res, e, 'approveVenue'); + } +}; + +export const rejectVenue = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const dto = req.validated?.body as z.infer; + const venue = await service.rejectVenue(id, adminId, dto); + ResponseUtil.success(res, 'Venue rejected', venue); + } catch (e) { + handleError(res, e, 'rejectVenue'); + } +}; + +export const unsuspendVenue = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const venue = await service.unsuspendVenue(id, adminId); + ResponseUtil.success(res, 'Venue reactivated successfully', venue); + } catch (e) { + handleError(res, e, 'unsuspendVenue'); + } +}; + +export const suspendVenue = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const dto = req.validated?.body as z.infer; + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const venue = await service.suspendVenue(id, adminId, dto); + ResponseUtil.success(res, 'Venue suspended successfully', venue); + } catch (e) { + handleError(res, e, 'suspendVenue'); + } +}; + +export const featureVenue = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const dto = req.validated?.body as z.infer; + await service.featureVenue( + id, + dto.durationDays === 'indefinite' ? null : parseInt(dto.durationDays, 10) + ); + ResponseUtil.success(res, 'Venue featured status updated successfully'); + } catch (e) { + handleError(res, e, 'featureVenue'); + } +}; + +export const getFeaturedVenues = async (req: Request, res: Response): Promise => { + try { + const venues = await service.getFeaturedVenues(); + + let finalVenues: (PlainVenue & { wishlisted?: boolean; featuredExpiresAt?: Date | null })[] = + venues; + if (req.user?.userId) { + const venueIds = venues.map((v) => v._id.toString()); + const wishlistedStatuses = await wishlistService.getWishlistStatus(req.user.userId, venueIds); + finalVenues = venues.map((venue) => ({ + ...venue, + wishlisted: wishlistedStatuses[venue._id.toString()] ?? false, + featuredExpiresAt: venue.featuredExpiresAt, + })); + } else { + finalVenues = venues.map((venue) => ({ + ...venue, + wishlisted: false, + featuredExpiresAt: venue.featuredExpiresAt, + })); + } + + ResponseUtil.success(res, 'Featured venues retrieved successfully', finalVenues); + } catch (e) { + handleError(res, e, 'getFeaturedVenues'); + } +}; + +export const unfeatureVenue = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + await service.unfeatureVenue(id); + ResponseUtil.success(res, 'Venue removed from featured successfully'); + } catch (e) { + handleError(res, e, 'unfeatureVenue'); + } +}; + +export const getReviewsList = async (_req: Request, res: Response): Promise => { + try { + const venues = await service.getReviewsList(); + ResponseUtil.success(res, 'Review list retrieved successfully', { + count: venues.length, + venues, + }); + } catch (e) { + handleError(res, e, 'getReviewsList'); + } +}; + +export const approveReview = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const dto = req.validated?.body as z.infer | undefined; + const venue = await service.approveReview(id, adminId, dto?.note); + ResponseUtil.success(res, 'Review approved successfully', venue); + } catch (e) { + handleError(res, e, 'approveReview'); + } +}; + +export const rejectReview = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const dto = req.validated?.body as z.infer; + const venue = await service.rejectReview(id, adminId, dto.note); + ResponseUtil.success(res, 'Review rejected', venue); + } catch (e) { + handleError(res, e, 'rejectReview'); + } +}; + +export const extendDeadline = async (req: Request, res: Response): Promise => { + try { + const { id } = req.validated?.params as z.infer; + const { newDeadline } = req.validated?.body as z.infer; + const adminId = req.user?.userId; + if (!adminId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + const venue = await service.extendVenueEditDeadline(id, adminId, newDeadline); + ResponseUtil.success(res, 'Edit deadline extended successfully', venue); + } catch (e) { + handleError(res, e, 'extendDeadline'); + } +}; diff --git a/server/src/modules/venue/venue.model.ts b/server/src/modules/venue/venue.model.ts new file mode 100644 index 0000000000..75664d93b5 --- /dev/null +++ b/server/src/modules/venue/venue.model.ts @@ -0,0 +1,245 @@ +import mongoose, { Schema } from 'mongoose'; +import { VenueStatusEnum, ReviewIntent } from '../../constants/venue.constants'; +import type { + IGeoPoint, + IFixedPackage, + IPricingRule, + IPricing, + IBlockedTime, + IRefundRule, + IContact, + ICancellation, + IVenue, +} from './venue.types'; + +const GeoPointSchema = new Schema( + { + type: { type: String, enum: ['Point'], required: true, default: 'Point' }, + coordinates: { + type: [Number], + required: true, + validate: { + validator: (v: number[]): boolean => v.length === 2, + message: 'coordinates must be [longitude, latitude]', + }, + }, + }, + { _id: false } +); + +const FixedPackageSchema = new Schema( + { + slotName: { type: String, required: true }, + startTime: { type: String, required: true }, + endTime: { type: String, required: true }, + price: { type: Number, required: true }, + }, + { _id: false } +); + +const PricingRuleSchema = new Schema( + { + fromTime: { type: String, required: true }, + toTime: { type: String, required: true }, + price: { type: Number, required: true }, + }, + { _id: false } +); + +const PricingSchema = new Schema( + { + pricingType: { type: String, enum: ['fixedPricing', 'timeBasedPricing'], required: true }, + basePrice: { type: Number, required: true, min: 0 }, + pricingRules: { type: [PricingRuleSchema], default: [] }, + }, + { _id: false } +); + +const BlockedTimeSchema = new Schema( + { + fromTime: { type: String, required: true }, + toTime: { type: String, required: true }, + }, + { _id: false } +); + +const RefundRuleSchema = new Schema( + { + daysBefore: { type: Number, required: true }, + refundPercentage: { type: Number, required: true }, + }, + { _id: false } +); + +const ContactSchema = new Schema( + { + name: { type: String, required: true, trim: true }, + phone: { type: String, required: true, trim: true }, + email: { type: String, trim: true, default: null }, + }, + { _id: false } +); + +const CancellationSchema = new Schema( + { + policy: { type: String, enum: ['refundable', 'nonRefundable'], required: true }, + refundType: { type: String, enum: ['fullRefund', 'timeBasedRefund'] }, + refundRules: { type: [RefundRuleSchema], default: [] }, + }, + { _id: false } +); + +const RejectionEntrySchema = new Schema( + { + reason: { type: String, required: true, maxlength: 500 }, + rejectedAt: { type: Date, default: Date.now }, + rejectedBy: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + submissionNumber: { type: Number, required: true }, + editDeadline: { type: Date, required: true }, + extendedAt: { type: Date }, + extendedBy: { type: Schema.Types.ObjectId, ref: 'Users' }, + originalDeadline: { type: Date }, + }, + { _id: true } +); + +const VenueSchema = new Schema( + { + // Basic Info + name: { type: String, required: true, trim: true, maxlength: 100 }, + description: { type: String, required: true, trim: true }, + venueType: { type: String, required: true }, + + // Location + address: { type: String, required: true, trim: true }, + city: { type: String, required: true, trim: true }, + district: { type: String, required: true, trim: true }, + pincode: { type: String, required: true, trim: true }, + location: { type: GeoPointSchema, required: false }, + googleMapsUrl: { type: String, trim: true, default: null }, + + // Space & Capacity + spaceAttributes: { type: [String], default: [] }, + seatingConfigurations: { type: [String], default: [] }, + maxCapacity: { type: Number }, + + // Booking Config + bookingType: { type: String, enum: ['fixedBooking', 'flexibleBooking'], required: true }, + fixedPackages: { type: [FixedPackageSchema], default: [] }, + workingDays: { type: [String], default: [] }, + workingHours: { + open: { type: String, required: false }, + close: { type: String, required: false }, + }, + blockedTimes: { type: [BlockedTimeSchema], default: [] }, + blockedDates: { type: [Date], default: [] }, + flexibleBooking: { + slotDuration: { type: Number, default: 60 }, + bufferTime: { type: Number, default: 0 }, + }, + + // Pricing + pricing: { type: PricingSchema, required: false }, + + // Amenities + amenities: { type: [String], default: [] }, + + // Media + coverImage: { type: String, required: true, trim: true }, + galleryImages: { type: [String], default: [] }, + + // Contact + contact: { type: ContactSchema, required: true }, + + // Cancellation & Refund + cancellation: { type: CancellationSchema, required: true }, + + // Ratings & Reviews + avgRating: { type: Number, default: 0, min: 0, max: 5 }, + reviewCount: { type: Number, default: 0, min: 0 }, + + pendingReview: { + type: { + intent: { type: String, enum: Object.values(ReviewIntent) }, + requestedAt: { type: Date }, + details: { + type: { + changedFields: [{ type: String }], + previousSnapshot: { type: Schema.Types.Mixed }, + reason: { type: String }, + }, + default: {}, + }, + }, + default: undefined, + }, + + inactivity: { + type: { + requestedAt: { type: Date }, + approvedAt: { type: Date }, + blockedAfterDate: { type: Date }, + inactiveAt: { type: Date }, + lastInactiveAt: { type: Date }, + withdrawalRequestedAt: { type: Date }, + }, + default: undefined, + }, + + temporaryBlockAfterDate: { type: Date, default: undefined }, + + // Operational + status: { + type: String, + enum: VenueStatusEnum as unknown as string[], + required: true, + default: 'Draft', + }, + ownerUserId: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + rejectionHistory: { + type: [RejectionEntrySchema], + default: [], + validate: { + validator: (v: unknown[]): boolean => v.length <= 10, + message: 'Maximum 10 submission attempts exceeded', + }, + }, + submissionCount: { type: Number, default: 0 }, + lastSubmittedAt: { type: Date }, + currentEditDeadline: { type: Date }, + suspensionReason: { type: String, default: null }, + + // Audit + createdBy: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + updatedBy: { type: Schema.Types.ObjectId, ref: 'Users', required: true }, + + // Soft delete + active: { type: Boolean, default: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true } +); + +// Indexes +VenueSchema.index( + { ownerUserId: 1, name: 1 }, + { + unique: true, + partialFilterExpression: { deleted: false }, + name: 'idx_owner_name_unique', + } +); + +// Admin: fetch all pending venues +VenueSchema.index({ status: 1, deleted: 1 }, { name: 'idx_status' }); + +// Owner: list own venues +VenueSchema.index({ ownerUserId: 1, status: 1, deleted: 1 }, { name: 'idx_owner_status' }); + +// Future geo-search readiness (2dsphere on the nested GeoJSON Point) +VenueSchema.index({ location: '2dsphere' }, { name: 'idx_location_geo' }); + +// For auto-suspend job query +VenueSchema.index({ status: 1, currentEditDeadline: 1 }, { name: 'idx_status_edit_deadline' }); + +export const VenueModel = mongoose.model('Venues', VenueSchema, 'Venues'); diff --git a/server/src/modules/venue/venue.ownership.ts b/server/src/modules/venue/venue.ownership.ts new file mode 100644 index 0000000000..69678f88ef --- /dev/null +++ b/server/src/modules/venue/venue.ownership.ts @@ -0,0 +1,29 @@ +import type { IVenue } from './venue.types'; +import { ForbiddenError, NotFoundError } from '../../utils/errors'; +import { findVenueById } from './venue.repository'; + +export function assertVenueOwner(venue: IVenue, userId: string): void { + if (venue.ownerUserId.toString() !== userId) { + throw new ForbiddenError('You do not have permission to modify this venue'); + } +} + +export async function requireOwnVenue(venueId: string, userId: string): Promise { + const venue = await findVenueById(venueId); + + if (!venue) { + throw new NotFoundError('Venue not found'); + } + + assertVenueOwner(venue, userId); + + const { isBannedForScope } = await import('../moderation/bannedUser.service.js'); + const isBanned = await isBannedForScope(userId, 'owner_dashboard', venueId); + if (isBanned) { + throw new ForbiddenError( + 'You are currently banned from accessing the owner dashboard for this venue.' + ); + } + + return venue; +} diff --git a/server/src/modules/venue/venue.repository.ts b/server/src/modules/venue/venue.repository.ts new file mode 100644 index 0000000000..89f76dae79 --- /dev/null +++ b/server/src/modules/venue/venue.repository.ts @@ -0,0 +1,459 @@ +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import { buildPaginationMeta } from '../../utils/paginationUtils'; +import type { + IVenue, + VenueStatus, + AdminVenueFilters, + CreateVenueData, + UpdateVenueData, + PublicVenueFilters, +} from './venue.types'; +import { VenueModel } from './venue.model'; +import { VenueDraftModel, type IVenueDraft } from './venueDraft.model'; +import { FeaturedVenueModel } from '../../models/featured-venue.model'; +import mongoose from 'mongoose'; + +// Helpers + +const toObjectId = (id: string): mongoose.Types.ObjectId => { + return new mongoose.Types.ObjectId(id); +}; + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export async function findActiveVenues(): Promise { + return VenueModel.find({ status: 'Approved', active: true, deleted: false }) + .sort({ createdAt: -1 }) + .select( + '_id name description venueType city district coverImage maxCapacity avgRating reviewCount flexibleBooking amenities pricing fixedPackages bookingType' + ) + .exec(); +} + +// Get lightweight venue pins for map view +export async function findVenuePinsInBounds(bbox?: { + swLng: number; + swLat: number; + neLng: number; + neLat: number; +}): Promise< + { + _id: string; + name: string; + location: { coordinates: [number, number] }; + coverImage: string; + avgRating: number; + }[] +> { + const query: Record = { + status: 'Approved', + active: true, + deleted: false, + location: { $exists: true }, + }; + + // If bbox is provided, use $geoWithin to filter by bounds + if (bbox) { + query.location = { + $geoWithin: { + $box: [ + [bbox.swLng, bbox.swLat], + [bbox.neLng, bbox.neLat], + ], + }, + }; + } + + return VenueModel.find(query) + .select('_id name location coverImage avgRating') + .lean() + .exec() as unknown as Promise< + { + _id: string; + name: string; + location: { coordinates: [number, number] }; + coverImage: string; + avgRating: number; + }[] + >; +} + +export async function findPaginatedActiveVenues( + paginationParams: PaginationParams, + filters?: PublicVenueFilters +): Promise> { + const { limit, skip } = paginationParams; + const conditions: Record[] = [ + { status: 'Approved', active: true, deleted: false }, + ]; + + if (filters?.searchTerm) { + const searchRegex = new RegExp(escapeRegex(filters.searchTerm), 'i'); + conditions.push({ + $or: [ + { name: searchRegex }, + { city: searchRegex }, + { district: searchRegex }, + { description: searchRegex }, + ], + }); + } + + if (filters?.minPrice !== undefined || filters?.maxPrice !== undefined) { + const priceCondition: { $gte?: number; $lte?: number } = {}; + if (filters.minPrice !== undefined) priceCondition.$gte = filters.minPrice; + if (filters.maxPrice !== undefined) priceCondition.$lte = filters.maxPrice; + + conditions.push({ + $or: [ + { 'pricing.basePrice': priceCondition }, + { 'pricing.pricingRules.price': priceCondition }, + { 'fixedPackages.price': priceCondition }, + ], + }); + } + + if (filters?.venueType && filters.venueType.length > 0) { + conditions.push({ venueType: { $in: filters.venueType } }); + } + + if (filters?.district) { + conditions.push({ + district: { $regex: new RegExp(`^${escapeRegex(filters.district)}$`, 'i') }, + }); + } + + if (filters?.capacity) { + conditions.push({ maxCapacity: { $gte: filters.capacity } }); + } + + if (filters?.spaceAttributes && filters.spaceAttributes.length > 0) { + conditions.push({ spaceAttributes: { $in: filters.spaceAttributes } }); + } + + if (filters?.seatingConfigurations && filters.seatingConfigurations.length > 0) { + conditions.push({ seatingConfigurations: { $in: filters.seatingConfigurations } }); + } + + if (filters?.amenities && filters.amenities.length > 0) { + conditions.push({ amenities: { $all: filters.amenities } }); + } + + // Geospatial: $geoWithin/$centerSphere for composability with other filters + if (filters?.lat !== undefined && filters.lng !== undefined) { + const radiusKm = filters.radiusKm ?? 25; + const radiusRadians = radiusKm / 6378.1; // Earth's mean radius in km + + conditions.push({ + location: { + $geoWithin: { + $centerSphere: [[filters.lng, filters.lat], radiusRadians], + }, + }, + }); + } + + const query = { $and: conditions }; + + let sortOption: Record = { createdAt: -1, _id: -1 }; + if (filters?.sortBy) { + switch (filters.sortBy) { + case 'price-low': + sortOption = { 'pricing.basePrice': 1, _id: 1 }; + break; + case 'price-high': + sortOption = { 'pricing.basePrice': -1, _id: -1 }; + break; + case 'rating': + sortOption = { avgRating: -1, reviewCount: -1, _id: -1 }; + break; + case 'distance': + // For distance sorting, must use $near, which auto-sorts by distance + // This branch handles the special case where distance-sort is requested + sortOption = { createdAt: -1, _id: -1 }; // Fallback; prefer lat+lng sorting + break; + default: + sortOption = { createdAt: -1, _id: -1 }; + break; + } + } + + const [venues, total] = await Promise.all([ + VenueModel.find(query) + .sort(sortOption) + .skip(skip) + .limit(limit) + .select( + '_id name description venueType city district coverImage maxCapacity avgRating reviewCount flexibleBooking amenities pricing fixedPackages bookingType' + ) + .exec(), + VenueModel.countDocuments(query).exec(), + ]); + + return { + venues, + pagination: buildPaginationMeta(total, paginationParams), + }; +} + +// Read Operations +export async function findVenueById( + venueId: string, + session?: mongoose.ClientSession +): Promise { + const query = VenueModel.findOne({ _id: toObjectId(venueId), deleted: false }); + if (session) query.session(session); + return query.exec(); +} + +// Check if an active venue with this name already exists for this owner +export async function existsByOwnerAndName( + ownerUserId: string, + name: string, + excludeVenueId?: string +): Promise { + const query: Record = { + ownerUserId: toObjectId(ownerUserId), + name: { $regex: new RegExp(`^${escapeRegex(name)}$`, 'i') }, + deleted: false, + }; + + if (excludeVenueId) { + query._id = { $ne: toObjectId(excludeVenueId) }; + } + + const count = await VenueModel.countDocuments(query).exec(); + return count > 0; +} + +export async function findVenueNameAndOwner( + venueId: string +): Promise<{ name: string; ownerUserId: mongoose.Types.ObjectId } | null> { + return VenueModel.findById(venueId).select('name ownerUserId').lean().exec(); +} + +export async function venueExists(venueId: string): Promise { + const exists = await VenueModel.exists({ _id: venueId, deleted: false }).exec(); + return exists !== null; +} + +export async function findVenuesByIds( + venueIds: string[] +): Promise<{ _id: mongoose.Types.ObjectId }[]> { + return VenueModel.find({ _id: { $in: venueIds }, deleted: false }) + .select('_id') + .lean() + .exec(); +} + +export async function findMyVenuesProjected( + ownerUserId: string +): Promise< + (Pick< + IVenue, + | '_id' + | 'name' + | 'city' + | 'district' + | 'venueType' + | 'coverImage' + | 'status' + | 'rejectionHistory' + | 'submissionCount' + | 'currentEditDeadline' + | 'suspensionReason' + | 'createdAt' + > & { rejectionReason?: string; isFeatured?: boolean })[] +> { + const [venues, featuredDocs] = await Promise.all([ + VenueModel.find( + { ownerUserId: toObjectId(ownerUserId), deleted: false }, + { + _id: 1, + name: 1, + city: 1, + district: 1, + venueType: 1, + coverImage: 1, + status: 1, + rejectionReason: 1, + rejectionHistory: 1, + submissionCount: 1, + currentEditDeadline: 1, + suspensionReason: 1, + createdAt: 1, + } + ) + .sort({ createdAt: -1 }) + .lean() + .exec(), + FeaturedVenueModel.find().select('venueId').lean().exec(), + ]); + + const featuredSet = new Set(featuredDocs.map((f) => f.venueId.toString())); + return venues.map((v) => ({ + ...v, + isFeatured: featuredSet.has(v._id.toString()), + })); +} + +// Admin +export async function findPendingVenues(): Promise { + return VenueModel.find({ + status: 'PendingReview', + deleted: false, + }) + .sort({ createdAt: 1 }) + .exec(); +} + +// Admin +export async function findAllVenues( + filters: AdminVenueFilters +): Promise> { + const { status, city, page, limit } = filters; + const skip = (page - 1) * limit; + + const query: Record = { deleted: false }; + if (status) query.status = status; + if (city) query.city = { $regex: new RegExp(`^${escapeRegex(city)}$`, 'i') }; + + const [rawVenues, totalCount, featuredDocs] = await Promise.all([ + VenueModel.find(query) + .sort({ createdAt: -1, _id: -1 }) + .skip(skip) + .limit(limit) + .populate('ownerUserId', 'username email') + .lean() + .exec(), + VenueModel.countDocuments(query).exec(), + FeaturedVenueModel.find().select('venueId').lean().exec(), + ]); + + const featuredSet = new Set(featuredDocs.map((f) => f.venueId.toString())); + + const venues = rawVenues.map((v) => ({ + ...v, + isActive: v.status === 'Approved', + isFeatured: featuredSet.has(v._id.toString()), + })) as unknown as IVenue[]; + + return { + venues, + pagination: buildPaginationMeta(totalCount, { page, limit, skip, sort: '-createdAt' }), + }; +} + +// Write Operations +export async function createVenue(data: CreateVenueData): Promise { + const venue = new VenueModel(data); + return venue.save(); +} + +// Draft Operations +export async function upsertDraft( + userId: string, + step: number, + formValues: Record +): Promise { + return VenueDraftModel.findOneAndUpdate( + { userId: toObjectId(userId) }, + { $set: { step, formValues } }, + { new: true, upsert: true } + ).exec(); +} + +export async function getDraft(userId: string): Promise { + return VenueDraftModel.findOne({ userId: toObjectId(userId) }).exec(); +} + +export async function deleteDraft(userId: string): Promise { + await VenueDraftModel.deleteOne({ userId: toObjectId(userId) }).exec(); +} + +export async function updateVenue(venueId: string, patch: UpdateVenueData): Promise { + return VenueModel.findOneAndUpdate( + { _id: toObjectId(venueId), deleted: false }, + { $set: patch }, + { new: true, runValidators: true } + ).exec(); +} + +export async function softDeleteVenue(venueId: string, updatedBy: string): Promise { + const result = await VenueModel.updateOne( + { _id: toObjectId(venueId), deleted: false }, + { + $set: { + deleted: true, + active: false, + updatedBy: toObjectId(updatedBy), + }, + } + ).exec(); + + return result.modifiedCount > 0; +} + +// State Machine Operations +export async function updateVenueStatus( + venueId: string, + newStatus: VenueStatus, + updatedBy: string, + extraFields?: Partial +): Promise { + const patch: mongoose.UpdateQuery = { + $set: { + status: newStatus, + updatedBy: toObjectId(updatedBy), + ...(extraFields ?? {}), + }, + }; + + if (newStatus !== 'Rejected') { + patch.$unset = { ...patch.$unset, rejectionReason: '' }; + } + + if (newStatus !== 'Suspended') { + patch.$unset = { ...patch.$unset, suspensionReason: '' }; + } + + return VenueModel.findOneAndUpdate({ _id: toObjectId(venueId), deleted: false }, patch, { + new: true, + runValidators: true, + }).exec(); +} + +export async function upsertFeaturedVenue( + venueId: string, + durationDays: number | null +): Promise { + const expiresAt = durationDays ? new Date(Date.now() + durationDays * 24 * 60 * 60 * 1000) : null; + + await FeaturedVenueModel.findOneAndUpdate( + { venueId: toObjectId(venueId) }, + { $set: { expiresAt } }, + { upsert: true, new: true } + ).exec(); +} + +export async function getFeaturedVenues(): Promise< + (IVenue & { featuredExpiresAt?: Date | null })[] +> { + const featured = await FeaturedVenueModel.find() + .populate<{ venueId: IVenue }>('venueId') + .lean() + .exec(); + + return featured + .filter((f) => Boolean(f.venueId) && f.venueId.status === 'Approved') + .map((f) => ({ + ...f.venueId, + featuredExpiresAt: f.expiresAt, + })) as (IVenue & { featuredExpiresAt?: Date | null })[]; +} + +export async function removeFeaturedVenue(venueId: string): Promise { + await FeaturedVenueModel.findOneAndDelete({ venueId: toObjectId(venueId) }).exec(); +} diff --git a/server/src/modules/venue/venue.router.ts b/server/src/modules/venue/venue.router.ts new file mode 100644 index 0000000000..9c885dd236 --- /dev/null +++ b/server/src/modules/venue/venue.router.ts @@ -0,0 +1,922 @@ +import { Router } from 'express'; +import { verifyAccessToken, verifyAccessTokenOptional } from '../../middlewares/auth.middleware'; +import { requirePermission, requireRole } from '../../middlewares/rbac.middleware'; +import { PERMISSIONS as P } from '../../constants/permissions'; +import * as controller from './venue.controller'; +import { + validateBody, + validateParams, + validateQuery, +} from '../../middlewares/validation.middleware'; +import * as validator from './venue.validator'; +import rateLimit from 'express-rate-limit'; +import { paginationMiddleware } from '../../middlewares/pagination.middleware'; +import { idempotencyMiddleware } from '../../middlewares/idempotency.middleware'; + +const router: Router = Router(); + +const uploadSignatureLimiter = rateLimit({ + windowMs: 30 * 60 * 1000, + max: 20, + message: 'Too many upload requests, please try again later', +}); + +/** + * @openapi + * /venues/upload-signature: + * get: + * tags: [Venues] + * summary: Get a Cloudinary upload signature for direct browser uploads + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Cloudinary signature and upload parameters + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * data: + * type: object + * properties: + * signature: + * type: string + * timestamp: + * type: integer + * cloudName: + * type: string + * apiKey: + * type: string + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * 429: + * description: Too many upload signature requests + */ +router + .route('/upload-signature') + .get(verifyAccessToken, uploadSignatureLimiter, controller.getUploadSignature); + +/** + * @openapi + * /venues: + * post: + * tags: [Venues] + * summary: Create a new venue + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * description: Venue creation payload — validated by createVenueSchema + * responses: + * 201: + * description: Venue created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 400: + * description: Validation error + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * get: + * tags: [Venues] + * summary: List active venues (public, paginated, filterable) + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * - in: query + * name: searchTerm + * schema: + * type: string + * description: Search venues by name or description + * - in: query + * name: minPrice + * schema: + * type: number + * - in: query + * name: maxPrice + * schema: + * type: number + * - in: query + * name: venueType + * schema: + * type: string + * description: Filter by venue type + * - in: query + * name: district + * schema: + * type: string + * description: Filter by Kerala district + * - in: query + * name: capacity + * schema: + * type: integer + * description: Minimum capacity required + * - in: query + * name: spaceAttributes + * schema: + * type: string + * description: Comma-separated or single space attribute + * - in: query + * name: seatingConfigurations + * schema: + * type: string + * description: Comma-separated or single seating configuration + * - in: query + * name: amenities + * schema: + * type: string + * description: Comma-separated or single amenity + * - in: query + * name: lat + * schema: + * type: number + * description: Latitude for geo-search (must be paired with lng) + * - in: query + * name: lng + * schema: + * type: number + * description: Longitude for geo-search (must be paired with lat) + * - in: query + * name: radiusKm + * schema: + * type: number + * default: 25 + * description: Search radius in km for geo queries + * - in: query + * name: sortBy + * schema: + * type: string + * enum: [price-low, price-high, rating, distance] + * responses: + * 200: + * description: Paginated list of active venues + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * meta: + * $ref: '#/components/schemas/PaginatedMeta' + */ +router + .route('/') + .post(verifyAccessToken, validateBody(validator.createVenueSchema), controller.createVenue) + .get( + verifyAccessTokenOptional, + validateQuery(validator.publicVenueFiltersSchema), + paginationMiddleware(), + controller.getPaginatedActiveVenues + ); + +/** + * @openapi + * /venues/my-venues: + * get: + * tags: [Venues] + * summary: List the authenticated owner's venues + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Array of venues owned by the current user + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + */ +router + .route('/my-venues') + .get(verifyAccessToken, requirePermission(P.venues.read), controller.getMyVenues); + +/** + * @openapi + * /venues/pins: + * get: + * tags: [Venues] + * summary: Get lightweight venue pins for map view (geospatial filtering) + * parameters: + * - in: query + * name: swLng + * schema: + * type: number + * description: Southwest longitude (bounding box) + * - in: query + * name: swLat + * schema: + * type: number + * description: Southwest latitude + * - in: query + * name: neLng + * schema: + * type: number + * description: Northeast longitude + * - in: query + * name: neLat + * schema: + * type: number + * description: Northeast latitude + * responses: + * 200: + * description: Array of venue pins with location and rating + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * data: + * type: array + * items: + * type: object + */ +router.route('/pins').get(controller.getVenuePins); + +/** + * @openapi + * /venues/draft: + * put: + * tags: [Venues] + * summary: Create or update the owner's draft venue + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * description: Partial venue payload for draft persistence + * responses: + * 200: + * description: Draft saved + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 401: + * description: Not authenticated + * get: + * tags: [Venues] + * summary: Get the owner's current draft venue + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Draft venue object + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 401: + * description: Not authenticated + * 404: + * description: No draft found + */ +router + .route('/draft') + .put(verifyAccessToken, controller.upsertDraft) + .get(verifyAccessToken, requirePermission(P.venues.read), controller.getMyDraft); + +/** + * @openapi + * /venues/pending: + * get: + * tags: [Venues] + * summary: List venues pending admin approval (admin only) + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Array of pending venues + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + */ +router + .route('/pending') + .get( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.activate), + controller.getPendingVenues + ); + +/** + * @openapi + * /venues/reviews: + * get: + * tags: [Venues] + * summary: List all venues with pending reviews grouped by intent (admin only) + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Array of venues with pending reviews + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + */ +router + .route('/reviews') + .get( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.activate), + controller.getReviewsList + ); + +/** + * @openapi + * /venues/all: + * get: + * tags: [Venues] + * summary: List all venues across all statuses (admin only, paginated) + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: status + * schema: + * type: string + * enum: [draft, pending, active, rejected, deactivated] + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * responses: + * 200: + * description: Paginated list of all venues + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + */ +router + .route('/all') + .get( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.activate), + validateQuery(validator.adminVenueFiltersSchema), + paginationMiddleware(), + controller.getAllVenues + ); + +/** + * @openapi + * /venues/featured: + * get: + * tags: [Venues] + * summary: Get all featured venues (public) + * responses: + * 200: + * description: Array of featured venues + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + */ +router.route('/featured').get(verifyAccessTokenOptional, controller.getFeaturedVenues); + +/** + * @openapi + * /venues/{id}: + * get: + * tags: [Venues] + * summary: Get a venue by ID (public) + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * example: 64b1f2c3d4e5f6a7b8c9d0e1 + * responses: + * 200: + * description: Venue details + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 400: + * description: Invalid venue ID format + * 404: + * description: Venue not found + * put: + * tags: [Venues] + * summary: Update a venue (owner) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * description: Fields to update — validated by updateVenueSchema + * responses: + * 200: + * description: Venue updated + * 400: + * description: Validation error + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * 404: + * description: Venue not found + * delete: + * tags: [Venues] + * summary: Delete a venue (owner) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Venue deleted + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * 404: + * description: Venue not found + */ +router + .route('/:id') + .get( + verifyAccessTokenOptional, + validateParams(validator.venueIdParamSchema), + controller.getVenueById + ) + .put( + verifyAccessToken, + idempotencyMiddleware(), + requirePermission(P.venues.update), + validateParams(validator.venueIdParamSchema), + validateBody(validator.updateVenueSchema), + controller.updateVenue + ) + .delete( + verifyAccessToken, + requirePermission(P.venues.delete), + validateParams(validator.venueIdParamSchema), + controller.deleteVenue + ); + +/** + * @openapi + * /venues/{id}/submit: + * post: + * tags: [Venues] + * summary: Submit a venue for admin review + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Venue submitted for review + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * 404: + * description: Venue not found + */ +router + .route('/:id/submit') + .post( + verifyAccessToken, + requirePermission(P.venues.update), + validateParams(validator.venueIdParamSchema), + controller.submitVenue + ); + +/** + * @openapi + * /venues/{id}/approve: + * post: + * tags: [Venues] + * summary: Approve a venue (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Venue approved and set to active + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + * 404: + * description: Venue not found + */ +router + .route('/:id/approve') + .post( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.activate), + validateParams(validator.venueIdParamSchema), + controller.approveVenue + ); + +/** + * @openapi + * /venues/{id}/reject: + * post: + * tags: [Venues] + * summary: Reject a venue with a reason (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [reason] + * properties: + * reason: + * type: string + * example: Images do not meet quality standards + * responses: + * 200: + * description: Venue rejected + * 400: + * description: Rejection reason required + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + * 404: + * description: Venue not found + */ +router + .route('/:id/reject') + .post( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.deactivate), + validateParams(validator.venueIdParamSchema), + validateBody(validator.rejectVenueSchema), + controller.rejectVenue + ); + +/** + * @openapi + * /venues/{id}/unsuspend: + * post: + * tags: [Venues] + * summary: Activate a venue (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Venue activated + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + * 404: + * description: Venue not found + */ +router + .route('/:id/unsuspend') + .post( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.activate), + validateParams(validator.venueIdParamSchema), + controller.unsuspendVenue + ); + +/** + * @openapi + * /venues/{id}/deactivate: + * post: + * tags: [Venues] + * summary: Deactivate a venue (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Venue deactivated + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + * 404: + * description: Venue not found + */ +router + .route('/:id/deactivate') + .post( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.deactivate), + validateParams(validator.venueIdParamSchema), + validateBody(validator.suspendVenueSchema), + controller.suspendVenue + ); + +/** + * @openapi + * /venues/{id}/feature: + * post: + * tags: [Venues] + * summary: Feature a venue (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [duration] + * properties: + * duration: + * type: integer + * nullable: true + * description: Duration in days to feature, or null for indefinite + * responses: + * 200: + * description: Venue featured status updated + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + * 404: + * description: Venue not found + * 409: + * description: Only approved venues can be featured + * delete: + * tags: [Venues] + * summary: Unfeature a venue (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Venue removed from featured list + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + * 404: + * description: Venue not found + */ +router + .route('/:id/feature') + .post( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.activate), + validateParams(validator.venueIdParamSchema), + validateBody(validator.featureVenueSchema), + controller.featureVenue + ) + .delete( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.activate), + validateParams(validator.venueIdParamSchema), + controller.unfeatureVenue + ); + +/** + * @openapi + * /venues/{id}/extend-deadline: + * post: + * tags: [Venues] + * summary: Extend edit deadline for a rejected venue (superAdmin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [newDeadline] + * properties: + * newDeadline: + * type: string + * format: date-time + * description: New deadline for editing (must be within 120 days) + * responses: + * 200: + * description: Edit deadline extended successfully + * 400: + * description: Invalid deadline + * 401: + * description: Not authenticated + * 403: + * description: SuperAdmin role required + * 404: + * description: Venue not found + */ +router + .route('/:id/extend-deadline') + .post( + verifyAccessToken, + requireRole('superAdmin'), + validateParams(validator.venueIdParamSchema), + validateBody(validator.extendVenueDeadlineSchema), + controller.extendDeadline + ); + +/** + * @openapi + * /venues/{id}/approve-review: + * post: + * tags: [Venues] + * summary: Approve a pending review (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * note: + * type: string + * responses: + * 200: + * description: Review approved + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + * 404: + * description: Venue not found + * 409: + * description: No pending review + */ +router + .route('/:id/approve-review') + .post( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.activate), + validateParams(validator.venueIdParamSchema), + validateBody(validator.approveReviewSchema), + idempotencyMiddleware(), + controller.approveReview + ); + +/** + * @openapi + * /venues/{id}/reject-review: + * post: + * tags: [Venues] + * summary: Reject a pending review (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [note] + * properties: + * note: + * type: string + * responses: + * 200: + * description: Review rejected + * 401: + * description: Not authenticated + * 403: + * description: Admin role required + * 404: + * description: Venue not found + * 409: + * description: No pending review + */ +router + .route('/:id/reject-review') + .post( + verifyAccessToken, + requireRole('admin'), + requirePermission(P.venues.deactivate), + validateParams(validator.venueIdParamSchema), + validateBody(validator.rejectReviewSchema), + idempotencyMiddleware(), + controller.rejectReview + ); + +export { router as venueRouter }; diff --git a/server/src/modules/venue/venue.service.ts b/server/src/modules/venue/venue.service.ts new file mode 100644 index 0000000000..b67f9815e1 --- /dev/null +++ b/server/src/modules/venue/venue.service.ts @@ -0,0 +1,931 @@ +import * as repo from './venue.repository'; +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import type { + CreateVenueData, + UpdateVenueData, + AdminVenueFilters, + IVenue, + PublicVenueFilters, +} from './venue.types'; +import type { IVenueDraft } from './venueDraft.model'; +import { requireOwnVenue } from './venue.ownership'; +import * as workflow from './venue.workflow'; +import { NotFoundError, ConflictError, ValidationError, WorkflowError } from '../../utils/errors'; +import type { + CreateVenueDTO, + UpdateVenueDTO, + RejectVenueDTO, + SuspendVenueDTO, + AdminVenueFiltersDTO, +} from './venue.validator'; +import { VenueFields, ReviewIntent } from '../../constants/venue.constants'; +import { VENUE_CONSTANTS } from '../../constants/venue.constants'; +import { VenueModel } from './venue.model'; +import { RoleModel } from '../../models/role.model'; +import { findUserEmailById } from '../user/user.repository'; +import * as authRepo from '../auth/auth.repository'; +import { getUserRole } from '../../services/roles.service'; +import { enqueueEmailTask } from '../../services/email.repository'; +import { EmailIntent, EmailTaskStatus } from '../../constants/email.constants'; +import mongoose from 'mongoose'; +import { logError, logWarn } from '../../utils/logger'; +import { BookingModel } from '../booking/models/booking.model'; +import { BookingStatus } from '../../constants/booking.constants'; + +// Google Maps URL Resolution & Transformation + +const SHORT_LINK_HOSTS = new Set(['goo.gl', 'maps.app.goo.gl']); + +const GOOGLE_MAPS_EMBED_HOSTS = new Set(['maps.google.com', 'www.google.com', 'google.com']); + +export async function resolveAndTransformGoogleMapsUrl( + inputUrl: string | undefined | null +): Promise { + if (!inputUrl) return null; + + try { + let workingUrl = inputUrl; + const parsed = new URL(workingUrl); + if (SHORT_LINK_HOSTS.has(parsed.hostname)) { + const response = await fetch(workingUrl, { + method: 'HEAD', + redirect: 'follow', + signal: AbortSignal.timeout(5_000), + }); + workingUrl = response.url; + } + + const resolved = new URL(workingUrl); + + // Step 2: Must be https and a known Google Maps domain + if (resolved.protocol !== 'https:' || !GOOGLE_MAPS_EMBED_HOSTS.has(resolved.hostname)) { + return null; + } + + // Step 3: Already an embed URL — return as-is + if (resolved.pathname.startsWith('/maps/embed')) { + return workingUrl; + } + + // Step 4: Extract a place search query from common share URL patterns + // Pattern: /maps/place//@lat,lng,... or /maps?q=... + const qParam = resolved.searchParams.get('q'); + const placeMatch = /\/maps\/place\/([^/]+)/.exec(resolved.pathname); + const coordMatch = /@(-?\d+\.\d+),(-?\d+\.\d+)/.exec(resolved.pathname); + + if (coordMatch) { + const lat = coordMatch[1]; + const lng = coordMatch[2]; + return `https://maps.google.com/maps?q=${lat},${lng}&output=embed&z=15`; + } + + if (qParam) { + return `https://maps.google.com/maps?q=${encodeURIComponent(qParam)}&output=embed&z=15`; + } + + if (placeMatch) { + const placeName = decodeURIComponent(placeMatch[1]); + return `https://maps.google.com/maps?q=${encodeURIComponent(placeName)}&output=embed&z=15`; + } + + return null; + } catch { + return null; + } +} + +export function sanitizeVenueDto( + dto: T, + currentBookingType?: 'fixedBooking' | 'flexibleBooking' +): T { + const bookingType = dto.bookingType ?? currentBookingType; + if (!bookingType) return dto; + + if (bookingType === 'fixedBooking') { + const { + flexibleBooking: _fb, + workingHours: _wh, + pricing: _pr, + blockedTimes: _bt, + ...rest + } = dto as Record; + return rest as T; + } + + const { fixedPackages: _fp, ...rest } = dto as Record; + return rest as T; +} + +async function assignOwnerRoleIfNeeded(userId: string): Promise { + const current = await getUserRole(userId); + + if (current && ['owner', 'admin', 'superAdmin'].includes(current.roleName)) { + return; + } + + const ownerRole = await RoleModel.findOne({ name: 'owner', active: true, deleted: false }) + .lean() + .exec(); + + if (!ownerRole) { + logError('assignOwnerRoleIfNeeded: owner role not found in DB — user not promoted', { + module: 'venue.service.ts/assignOwnerRoleIfNeeded', + userId, + }); + return; + } + + await authRepo.assignRoleToUser(new mongoose.Types.ObjectId(userId), ownerRole._id); +} + +export async function createVenue(userId: string, dto: CreateVenueDTO): Promise { + // Guard: Check for venue creation ban + const { isBannedForScope } = await import('../moderation/bannedUser.service.js'); + const isBanned = await isBannedForScope(userId, 'venue_creation'); + if (isBanned) { + throw new ConflictError('You are currently banned from creating new venues.'); + } + + const nameExists = await repo.existsByOwnerAndName(userId, dto.name); + if (nameExists) { + throw new ConflictError(`You already have a venue named "${dto.name}"`); + } + + const sanitizedDto = sanitizeVenueDto(dto); + + // Resolve and transform the Google Maps URL to a safe embed URL + const resolvedMapsUrl = await resolveAndTransformGoogleMapsUrl(sanitizedDto.googleMapsUrl); + + const now = new Date(); + const data = { + ...sanitizedDto, + googleMapsUrl: resolvedMapsUrl, + galleryImages: sanitizedDto.galleryImages ?? [], + ...(sanitizedDto.coordinates && { + location: { type: 'Point' as const, coordinates: sanitizedDto.coordinates }, + }), + ownerUserId: userId, + createdBy: userId, + updatedBy: userId, + status: 'PendingReview', + submissionCount: 1, + lastSubmittedAt: now, + } as unknown as CreateVenueData; + if (!dto.coordinates) delete (data as unknown as Record).location; + delete (data as unknown as Record).coordinates; + + const venue = await repo.createVenue(data); + + await assignOwnerRoleIfNeeded(userId); + await repo.deleteDraft(userId); + + return venue; +} + +export async function getActiveVenues(): Promise { + return repo.findActiveVenues(); +} + +export async function getPaginatedActiveVenues( + pagination: PaginationParams, + filters?: PublicVenueFilters +): Promise> { + return repo.findPaginatedActiveVenues(pagination, filters); +} + +export async function getVenuePins(bbox?: { + swLng: number; + swLat: number; + neLng: number; + neLat: number; +}): Promise< + { + _id: string; + name: string; + location: { coordinates: [number, number] }; + coverImage: string; + avgRating: number; + }[] +> { + return repo.findVenuePinsInBounds(bbox); +} + +export async function upsertDraft( + userId: string, + step: number, + formValues: Record +): Promise { + return repo.upsertDraft(userId, step, formValues); +} + +export async function getDraft(userId: string): Promise { + return repo.getDraft(userId); +} + +export async function getMyVenues( + userId: string +): Promise< + (Pick< + IVenue, + | '_id' + | 'name' + | 'city' + | 'district' + | 'venueType' + | 'coverImage' + | 'status' + | 'rejectionHistory' + | 'submissionCount' + | 'currentEditDeadline' + | 'suspensionReason' + | 'createdAt' + > & { rejectionReason?: string; isFeatured?: boolean })[] +> { + return repo.findMyVenuesProjected(userId); +} + +export async function getVenueById( + venueId: string, + requesterId?: string, + isPrivileged?: boolean +): Promise { + const venue = await repo.findVenueById(venueId); + + if (!venue) { + throw new NotFoundError('Venue not found'); + } + + const isOwner = requesterId !== undefined && venue.ownerUserId.toString() === requesterId; + if (venue.status !== 'Approved' && !isPrivileged && !isOwner) { + throw new NotFoundError('Venue not found'); + } + + return venue; +} + +const CRITICAL_FIELDS = new Set([ + 'name', + 'contact', + 'address', + 'city', + 'district', + 'pincode', + 'venueType', + 'bookingType', +]); + +function buildPatch( + _venue: IVenue, + dto: Record, + userId: string, + excludeFields?: string[] +): Record { + const patch: Record = { updatedBy: userId }; + for (const key of VenueFields) { + if (excludeFields?.includes(key)) continue; + if (dto[key] !== undefined) { + patch[key] = dto[key]; + } + } + if (dto.coordinates !== undefined) { + patch.location = { type: 'Point', coordinates: dto.coordinates }; + } + return patch; +} + +export async function updateVenue( + venueId: string, + userId: string, + dto: UpdateVenueDTO +): Promise { + const venue = await requireOwnVenue(venueId, userId); + + // Optimistic concurrency check + if (dto.expectedVersion !== undefined && venue.__v !== dto.expectedVersion) { + throw new ConflictError( + 'Venue has been modified by another request. Please reload and try again.' + ); + } + + if (dto.name && dto.name !== venue.name) { + const nameExists = await repo.existsByOwnerAndName(userId, dto.name, venueId); + if (nameExists) { + throw new ConflictError(`You already have a venue named "${dto.name}"`); + } + } + + const sanitizedDto = sanitizeVenueDto(dto, venue.bookingType); + + // Resolve and transform the Google Maps URL to a safe embed URL if provided + if (sanitizedDto.googleMapsUrl !== undefined) { + sanitizedDto.googleMapsUrl = + (await resolveAndTransformGoogleMapsUrl(sanitizedDto.googleMapsUrl)) ?? undefined; + } + + // Status-gated dispatch + if (venue.status === 'Draft' || venue.status === 'Rejected') { + const patch = buildPatch(venue, sanitizedDto, userId); + const updated = await repo.updateVenue(venueId, patch as unknown as UpdateVenueData); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; + } + + if (venue.status === 'Approved' || venue.status === 'Inactive') { + const dtoAny = sanitizedDto as unknown as Record; + const venueAny = venue as unknown as Record; + const changedCritical: string[] = []; + const snapshot: Record = {}; + + for (const field of CRITICAL_FIELDS) { + if (dtoAny[field] !== undefined && dtoAny[field] !== venueAny[field]) { + changedCritical.push(field); + snapshot[field] = venueAny[field]; + } + } + + if (changedCritical.length > 0) { + // Apply all fields immediately (Option B — snapshot is for rollback on reject) + const patch = buildPatch(venue, dtoAny, userId); + const updated = await repo.updateVenue(venueId, patch as UpdateVenueData); + if (!updated) throw new NotFoundError('Venue not found'); + + // Mark pending review with snapshot + await VenueModel.findByIdAndUpdate(venueId, { + $set: { + 'pendingReview.intent': ReviewIntent.VENUE_EDIT, + 'pendingReview.requestedAt': new Date(), + 'pendingReview.details.changedFields': changedCritical, + 'pendingReview.details.previousSnapshot': snapshot, + }, + }).exec(); + + // Re-fetch to include pendingReview + const withReview = await repo.findVenueById(venueId); + if (!withReview) throw new NotFoundError('Venue not found'); + return withReview; + } + + // Only non-critical fields — apply full patch immediately + const patch = buildPatch(venue, dtoAny, userId); + const updated = await repo.updateVenue(venueId, patch as unknown as UpdateVenueData); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; + } + + throw new WorkflowError(venue.status, 'edit'); +} + +export async function deleteVenue(venueId: string, userId: string): Promise { + const venue = await requireOwnVenue(venueId, userId); + workflow.canDelete(venue); + await repo.softDeleteVenue(venueId, userId); +} + +export async function submitVenue(venueId: string, userId: string): Promise { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const venue = await repo.findVenueById(venueId, session); + if (!venue) throw new NotFoundError('Venue not found'); + + // Ownership check + if (venue.ownerUserId.toString() !== userId) { + throw new ConflictError('Not authorized to submit this venue'); + } + + // Status must be Rejected + if (venue.status !== 'Rejected') { + throw new ValidationError('Only rejected venues can be resubmitted'); + } + + // Check max attempts + if (venue.submissionCount >= VENUE_CONSTANTS.MAX_SUBMISSION_ATTEMPTS) { + throw new ValidationError( + `Maximum submission attempts (${String(VENUE_CONSTANTS.MAX_SUBMISSION_ATTEMPTS)}) exceeded` + ); + } + + // Check edit deadline (race condition protection) + const now = new Date(); + if (venue.currentEditDeadline && venue.currentEditDeadline < now) { + throw new ValidationError('Edit window expired. Venue has been auto-suspended.'); + } + + // Validate all required fields present + workflow.canSubmit(venue); + + // Atomic update: Rejected -> PendingReview, clear deadline, increment count + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { + $set: { + status: 'PendingReview', + currentEditDeadline: null, + updatedBy: new mongoose.Types.ObjectId(userId), + lastSubmittedAt: now, + }, + $inc: { submissionCount: 1 }, + }, + { new: true, session } + ).exec(); + + if (!updated) throw new NotFoundError('Venue not found'); + + await session.commitTransaction(); + return updated; + } catch (err) { + await session.abortTransaction(); + throw err; + } finally { + await session.endSession(); + } +} + +// Admin Operations + +export async function getPendingVenues(): Promise { + return repo.findPendingVenues(); +} + +export async function getAllVenues( + filters: AdminVenueFiltersDTO +): Promise> { + const repoFilters: AdminVenueFilters = { + status: filters.status, + city: filters.city, + page: filters.page, + limit: filters.limit, + }; + return repo.findAllVenues(repoFilters); +} + +export async function approveVenue(venueId: string, adminId: string): Promise { + const venue = await repo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + + workflow.canApprove(venue); + + const updated = await repo.updateVenueStatus(venueId, 'Approved', adminId); + if (!updated) throw new NotFoundError('Venue not found'); + + const ownerEmail = await findUserEmailById(venue.ownerUserId.toString()); + if (ownerEmail) { + try { + await enqueueEmailTask( + ownerEmail, + EmailIntent.VENUE_APPROVED, + `Your Venue "${venue.name}" has been Approved!`, + EmailTaskStatus.PENDING, + { venueName: venue.name } + ); + } catch (err) { + logWarn('Failed to queue venue approved email', { + module: 'venue.service.ts/approveVenue', + venueId, + error: (err as Error).message, + }); + } + } + + return updated; +} + +export async function rejectVenue( + venueId: string, + adminId: string, + dto: RejectVenueDTO +): Promise { + const venue = await repo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + + workflow.canReject(venue); + + // Calculate edit deadline + let editDeadline = new Date(Date.now() + VENUE_CONSTANTS.EDIT_WINDOW_DAYS * 24 * 60 * 60 * 1000); + let extendedAt: Date | undefined; + let extendedBy: mongoose.Types.ObjectId | undefined; + let originalDeadline = editDeadline; + + if (dto.extendedDeadline) { + const minDeadline = new Date( + Date.now() + VENUE_CONSTANTS.EDIT_WINDOW_DAYS * 24 * 60 * 60 * 1000 + ); + const maxDeadline = new Date( + Date.now() + VENUE_CONSTANTS.MAX_EXTENDED_DAYS * 24 * 60 * 60 * 1000 + ); + + if (dto.extendedDeadline < minDeadline || dto.extendedDeadline > maxDeadline) { + throw new ValidationError( + `Extended deadline must be between ${String(VENUE_CONSTANTS.EDIT_WINDOW_DAYS)} and ${String(VENUE_CONSTANTS.MAX_EXTENDED_DAYS)} days` + ); + } + + editDeadline = dto.extendedDeadline; + extendedAt = new Date(); + extendedBy = new mongoose.Types.ObjectId(adminId); + originalDeadline = new Date( + Date.now() + VENUE_CONSTANTS.EDIT_WINDOW_DAYS * 24 * 60 * 60 * 1000 + ); + } + + const rejectionEntry = { + reason: dto.rejectionReason, + rejectedAt: new Date(), + rejectedBy: new mongoose.Types.ObjectId(adminId), + submissionNumber: venue.rejectionHistory.length + 1, + editDeadline, + extendedAt, + extendedBy, + originalDeadline, + }; + + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { + $set: { + status: 'Rejected', + rejectionReason: dto.rejectionReason, // backward compat + currentEditDeadline: editDeadline, + updatedBy: new mongoose.Types.ObjectId(adminId), + lastSubmittedAt: new Date(), + }, + $push: { rejectionHistory: rejectionEntry }, + }, + { new: true } + ).exec(); + + if (!updated) throw new NotFoundError('Venue not found'); + + const ownerEmail = await findUserEmailById(venue.ownerUserId.toString()); + if (ownerEmail && dto.rejectionReason) { + try { + await enqueueEmailTask( + ownerEmail, + EmailIntent.VENUE_REJECTED, + `Application Update: Venue "${venue.name}"`, + EmailTaskStatus.PENDING, + { + venueName: venue.name, + reason: dto.rejectionReason, + editDeadline: editDeadline.toISOString(), + submissionNumber: String(rejectionEntry.submissionNumber), + } + ); + } catch (err) { + logWarn('Failed to queue venue rejected email', { + module: 'venue.service.ts/rejectVenue', + venueId, + error: (err as Error).message, + }); + } + } + + // Log activity + const { logModerationAction } = await import('../moderation/moderationActivity.service.js'); + await logModerationAction(adminId, 'reject_venue', venueId, 'venue', dto.rejectionReason, { + submissionNumber: rejectionEntry.submissionNumber, + editDeadline, + isExtended: !!dto.extendedDeadline, + actor: 'admin', + }); + + return updated; +} + +export async function suspendVenue( + venueId: string, + adminId: string, + dto: SuspendVenueDTO +): Promise { + const venue = await repo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + + workflow.canDeactivate(venue); + + const extra = { suspensionReason: dto.suspensionReason }; + const updated = await repo.updateVenueStatus(venueId, 'Suspended', adminId, extra); + if (!updated) throw new NotFoundError('Venue not found'); + + const ownerEmail = await findUserEmailById(venue.ownerUserId.toString()); + if (ownerEmail) { + try { + await enqueueEmailTask( + ownerEmail, + EmailIntent.VENUE_SUSPENDED, + `Important: Your Venue "${venue.name}" has been Suspended`, + EmailTaskStatus.PENDING, + { venueName: venue.name, reason: dto.suspensionReason } + ); + } catch (err) { + logWarn('Failed to queue venue suspended email', { + module: 'venue.service.ts/suspendVenue', + venueId, + error: (err as Error).message, + }); + } + } + + // Log activity + const { logModerationAction } = await import('../moderation/moderationActivity.service.js'); + await logModerationAction(adminId, 'suspend_venue', venueId, 'venue', dto.suspensionReason); + + return updated; +} + +export async function unsuspendVenue(venueId: string, adminId: string): Promise { + const venue = await repo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + + workflow.canActivate(venue); + + const updated = await repo.updateVenueStatus(venueId, 'Approved', adminId); + if (!updated) throw new NotFoundError('Venue not found'); + + const ownerEmail = await findUserEmailById(venue.ownerUserId.toString()); + if (ownerEmail) { + try { + await enqueueEmailTask( + ownerEmail, + EmailIntent.VENUE_UNSUSPENDED, + `Your Venue "${venue.name}" has been Reactivated`, + EmailTaskStatus.PENDING, + { venueName: venue.name } + ); + } catch (err) { + logWarn('Failed to queue venue unsuspended email', { + module: 'venue.service.ts/unsuspendVenue', + venueId, + error: (err as Error).message, + }); + } + } + + // Log activity + const { logModerationAction } = await import('../moderation/moderationActivity.service.js'); + await logModerationAction(adminId, 'unsuspend_venue', venueId, 'venue'); + + return updated; +} + +export async function extendVenueEditDeadline( + venueId: string, + superAdminId: string, + newDeadline: Date +): Promise { + const venue = await repo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + + // Must be in Rejected status with active deadline + if (venue.status !== 'Rejected') { + throw new ValidationError('Can only extend deadline for rejected venues'); + } + if (!venue.currentEditDeadline || venue.currentEditDeadline < new Date()) { + throw new ValidationError('No active edit deadline to extend (already expired or suspended)'); + } + + // Validate new deadline + const minDeadline = new Date(); // must be in future + const maxDeadline = new Date( + Date.now() + VENUE_CONSTANTS.MAX_EXTENDED_DAYS * 24 * 60 * 60 * 1000 + ); + + if (newDeadline <= minDeadline || newDeadline > maxDeadline) { + throw new ValidationError( + `New deadline must be in the future and within ${String(VENUE_CONSTANTS.MAX_EXTENDED_DAYS)} days` + ); + } + if (newDeadline <= venue.currentEditDeadline) { + throw new ValidationError('New deadline must be later than current deadline'); + } + + // Update latest rejection history entry + const latestRejection = venue.rejectionHistory[venue.rejectionHistory.length - 1]; + + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { + $set: { + currentEditDeadline: newDeadline, + 'rejectionHistory.$[elem].editDeadline': newDeadline, + 'rejectionHistory.$[elem].extendedAt': new Date(), + 'rejectionHistory.$[elem].extendedBy': new mongoose.Types.ObjectId(superAdminId), + updatedBy: new mongoose.Types.ObjectId(superAdminId), + }, + }, + { + arrayFilters: [{ 'elem._id': latestRejection._id }], + new: true, + } + ).exec(); + + if (!updated) throw new NotFoundError('Venue not found'); + + // Log activity + const { logModerationAction } = await import('../moderation/moderationActivity.service.js'); + const previousDeadline = venue.currentEditDeadline.toISOString(); + await logModerationAction( + superAdminId, + 'extend_venue_deadline', + venueId, + 'venue', + `Extended edit deadline from ${previousDeadline} to ${newDeadline.toISOString()}`, + { actor: 'system (superadmin)', previousDeadline: venue.currentEditDeadline, newDeadline } + ); + + // Email owner about extension + const ownerEmail = await findUserEmailById(venue.ownerUserId.toString()); + if (ownerEmail) { + try { + await enqueueEmailTask( + ownerEmail, + EmailIntent.VENUE_DEADLINE_EXTENDED, + `Edit Deadline Extended for "${venue.name}"`, + EmailTaskStatus.PENDING, + { venueName: venue.name, newDeadline: newDeadline.toISOString() } + ); + } catch (err) { + logWarn('Failed to queue venue deadline extended email', { + module: 'venue.service.ts/extendVenueEditDeadline', + venueId, + error: (err as Error).message, + }); + } + } + + return updated; +} + +export async function featureVenue(venueId: string, durationDays: number | null): Promise { + const venue = await repo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + + if (venue.status !== 'Approved') { + throw new ConflictError('Only approved venues can be featured'); + } + + await repo.upsertFeaturedVenue(venueId, durationDays); +} + +export async function getFeaturedVenues(): Promise< + (IVenue & { featuredExpiresAt?: Date | null })[] +> { + return repo.getFeaturedVenues(); +} + +export async function unfeatureVenue(venueId: string): Promise { + const venue = await repo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + + await repo.removeFeaturedVenue(venueId); +} + +// Review Management (admin) + +export async function getReviewsList(): Promise { + return VenueModel.find({ + pendingReview: { $exists: true, $ne: null }, + deleted: false, + }) + .select('name city status pendingReview inactivity ownerUserId') + .populate('ownerUserId', 'username email') + .sort({ 'pendingReview.requestedAt': -1 }) + .lean() + .exec(); +} + +export async function approveReview( + venueId: string, + adminId: string, + _note?: string +): Promise { + const venue = await repo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + if (!venue.pendingReview) throw new ConflictError('Venue has no pending review'); + + const adminObjectId = new mongoose.Types.ObjectId(adminId); + + switch (venue.pendingReview.intent) { + case ReviewIntent.VENUE_EDIT: { + // Changes are already applied. Just clear pendingReview. + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { + $unset: { pendingReview: '' }, + $set: { updatedBy: adminObjectId }, + }, + { new: true } + ).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; + } + + case ReviewIntent.INACTIVITY_REQUEST: { + const lastFutureBooking = await BookingModel.findOne({ + venueId: new mongoose.Types.ObjectId(venueId), + status: BookingStatus.CONFIRMED, + date: { $gte: new Date().toISOString().split('T')[0] }, + }) + .sort({ date: -1 }) + .lean() + .exec(); + + let blockedAfterDate: Date; + if (lastFutureBooking) { + blockedAfterDate = new Date(lastFutureBooking.date); + blockedAfterDate.setDate(blockedAfterDate.getDate() + 1); + } else { + blockedAfterDate = new Date(); + } + + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { + $set: { + status: 'Inactive', + 'inactivity.approvedAt': new Date(), + 'inactivity.blockedAfterDate': blockedAfterDate, + 'inactivity.inactiveAt': new Date(), + updatedBy: adminObjectId, + }, + $unset: { pendingReview: '' }, + }, + { new: true } + ).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; + } + + case ReviewIntent.INACTIVITY_WITHDRAWAL: { + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { + $unset: { + pendingReview: '', + 'inactivity.requestedAt': '', + 'inactivity.approvedAt': '', + 'inactivity.blockedAfterDate': '', + 'inactivity.withdrawalRequestedAt': '', + }, + $set: { updatedBy: adminObjectId }, + }, + { new: true } + ).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; + } + + case ReviewIntent.DELETION_REQUEST: { + const deleted = await repo.softDeleteVenue(venueId, adminId); + if (!deleted) throw new NotFoundError('Venue not found'); + const updated = await repo.findVenueById(venueId); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; + } + + default: + throw new ValidationError( + `Cannot approve review with intent "${venue.pendingReview.intent}"` + ); + } +} + +export async function rejectReview( + venueId: string, + adminId: string, + _note: string +): Promise { + const venue = await repo.findVenueById(venueId); + if (!venue) throw new NotFoundError('Venue not found'); + if (!venue.pendingReview) throw new ConflictError('Venue has no pending review'); + + const adminObjectId = new mongoose.Types.ObjectId(adminId); + + if (venue.pendingReview.intent === ReviewIntent.VENUE_EDIT) { + const snapshot = venue.pendingReview.details.previousSnapshot; + if (snapshot && Object.keys(snapshot).length > 0) { + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { + $set: { ...snapshot, updatedBy: adminObjectId }, + $unset: { pendingReview: '' }, + }, + { new: true } + ).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; + } + } + + // For all other intents (and VENUE_EDIT without snapshot): just clear pendingReview + const updated = await VenueModel.findByIdAndUpdate( + venueId, + { + $unset: { pendingReview: '' }, + $set: { updatedBy: adminObjectId }, + }, + { new: true } + ).exec(); + if (!updated) throw new NotFoundError('Venue not found'); + return updated; +} diff --git a/server/src/modules/venue/venue.types.ts b/server/src/modules/venue/venue.types.ts new file mode 100644 index 0000000000..494a1b3acd --- /dev/null +++ b/server/src/modules/venue/venue.types.ts @@ -0,0 +1,213 @@ +import type mongoose from 'mongoose'; +import type { Document } from 'mongoose'; +import type { + VenueStatusEnum, + VenueFields, + ReviewIntentType, +} from '../../constants/venue.constants'; + +export type VenueStatus = (typeof VenueStatusEnum)[number]; + +export type PlainVenue = Omit & { _id: IVenue['_id'] }; + +export type VenueKey = (typeof VenueFields)[number]; + +export interface IGeoPoint { + type: 'Point'; + /** [longitude, latitude] — GeoJSON order */ + coordinates: [number, number]; +} + +export interface IFixedPackage { + slotName: string; + startTime: string; // 09:00 + endTime: string; // 13:00 + price: number; +} + +export interface IPricingRule { + fromTime: string; + toTime: string; + price: number; +} + +export interface IPricing { + pricingType: 'fixedPricing' | 'timeBasedPricing'; + basePrice: number; + pricingRules: IPricingRule[]; +} + +export interface IBlockedTime { + fromTime: string; + toTime: string; +} + +export interface IRefundRule { + daysBefore: number; + refundPercentage: number; +} + +export interface IContact { + name: string; + phone: string; + email?: string; +} + +export interface ICancellation { + policy: 'refundable' | 'nonRefundable'; + refundType?: 'fullRefund' | 'timeBasedRefund'; + refundRules: IRefundRule[]; +} + +export interface IRejectionEntry { + _id: mongoose.Types.ObjectId; + reason: string; + rejectedAt: Date; + rejectedBy: mongoose.Types.ObjectId; + submissionNumber: number; + editDeadline: Date; + extendedAt?: Date; + extendedBy?: mongoose.Types.ObjectId; + originalDeadline?: Date; +} + +// Main Interface +export interface IVenue extends Document { + __v?: number; + // Basic Info + name: string; + description: string; + venueType: string; + + // Location + address: string; + city: string; + district: string; + pincode: string; + location: IGeoPoint; // $geoNear / 2dsphere queries - future usecase + googleMapsUrl?: string; + + // Space & Capacity + spaceAttributes: string[]; + seatingConfigurations: string[]; + maxCapacity?: number; + + // Booking Config + bookingType: 'fixedBooking' | 'flexibleBooking'; + fixedPackages?: IFixedPackage[]; + workingDays: string[]; + workingHours?: { + open: string; + close: string; + }; + blockedTimes?: IBlockedTime[]; + blockedDates: Date[]; + flexibleBooking?: { + slotDuration: number; + bufferTime: number; + }; + + // Pricing + pricing?: IPricing; + + // Amenities + amenities: string[]; + + // Media + coverImage: string; + galleryImages: string[]; + + // Contact + contact: IContact; + + // Cancellation & Refund + cancellation: ICancellation; + + // Ratings & Reviews + avgRating: number; + reviewCount: number; + + pendingReview?: { + intent: ReviewIntentType; + requestedAt: Date; + details: { + changedFields?: string[]; + previousSnapshot?: Record; + reason?: string; + }; + }; + + inactivity?: { + requestedAt?: Date; + approvedAt?: Date; + blockedAfterDate?: Date; + inactiveAt?: Date; + lastInactiveAt?: Date; + withdrawalRequestedAt?: Date; + }; + + temporaryBlockAfterDate?: Date; + + // Operational + status: VenueStatus; + ownerUserId: mongoose.Types.ObjectId; + rejectionHistory: IRejectionEntry[]; + submissionCount: number; + lastSubmittedAt?: Date; + currentEditDeadline?: Date; + suspensionReason?: string; + + // Audit + createdBy: mongoose.Types.ObjectId; + updatedBy: mongoose.Types.ObjectId; + createdAt: Date; + updatedAt: Date; + + // Soft delete + active: boolean; + deleted: boolean; +} + +export type CreateVenueData = Omit< + IVenue, + | keyof mongoose.Document + | 'status' + | 'createdAt' + | 'updatedAt' + | 'active' + | 'deleted' + | 'rejectionHistory' + | 'submissionCount' + | 'lastSubmittedAt' + | 'currentEditDeadline' + | 'suspensionReason' +>; + +export type UpdateVenueData = Partial< + Omit +> & { + updatedBy: string; +}; + +export interface AdminVenueFilters { + status?: VenueStatus; + city?: string; + page: number; + limit: number; +} + +export interface PublicVenueFilters { + searchTerm?: string; + minPrice?: number; + maxPrice?: number; + venueType?: string[]; + district?: string; + capacity?: number; + spaceAttributes?: string[]; + seatingConfigurations?: string[]; + amenities?: string[]; + sortBy?: 'price-low' | 'price-high' | 'rating' | 'distance'; + lat?: number; + lng?: number; + radiusKm?: number; +} diff --git a/server/src/modules/venue/venue.validator.ts b/server/src/modules/venue/venue.validator.ts new file mode 100644 index 0000000000..060f957de2 --- /dev/null +++ b/server/src/modules/venue/venue.validator.ts @@ -0,0 +1,505 @@ +import { z } from 'zod'; +import { KERALA_DISTRICTS } from '../../constants/venue.constants'; +import { VENUE_CONSTANTS } from '../../constants/venue.constants'; + +// Shared sub-schemas + +/** [longitude, latitude] */ +const coordinatesSchema = z + .tuple([ + z.number().min(-180).max(180, 'Longitude must be between -180 and 180'), + z.number().min(-90).max(90, 'Latitude must be between -90 and 90'), + ]) + .describe('[longitude, latitude]'); + +const phoneSchema = z + .string() + .trim() + .regex(/^\+?[\d\s\-().]{7,20}$/, 'Contact phone must be a valid phone number (7-20 characters)'); + +const urlSchema = z.string().regex(/^https?:\/\/.+/, 'Must be a valid URL'); + +const GOOGLE_MAPS_ALLOWED_HOSTS = new Set([ + 'maps.google.com', + 'www.google.com', + 'google.com', + 'goo.gl', + 'maps.app.goo.gl', +]); + +const googleMapsUrlSchema = z + .url('Must be a valid URL') + .refine((url) => { + try { + const { protocol, hostname } = new URL(url); + return protocol === 'https:' && GOOGLE_MAPS_ALLOWED_HOSTS.has(hostname); + } catch { + return false; + } + }, 'Must be a valid Google Maps URL (google.com or maps.google.com)') + .optional(); + +const venueIdSchema = z + .string() + .trim() + .regex(/^[a-f\d]{24}$/i, 'Invalid venue ID'); + +const DAYS_OF_WEEK = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', +] as const; + +const fixedPackageSchema = z.object({ + slotName: z.string().trim().min(1, 'Slot name is required'), + startTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Invalid time format (HH:MM)'), + endTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Invalid time format (HH:MM)'), + price: z.coerce.number().min(0, 'Price cannot be negative'), +}); + +const pricingRuleSchema = z.object({ + fromTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Invalid time format (HH:MM)'), + toTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Invalid time format (HH:MM)'), + price: z.coerce.number().min(0, 'Price cannot be negative'), +}); + +const blockedTimeSchema = z.object({ + fromTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Invalid time format (HH:MM)'), + toTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Invalid time format (HH:MM)'), +}); + +export const refundRuleSchema = z.object({ + daysBefore: z.number().int().min(0, 'Days before must be positive'), + refundPercentage: z.number().min(0).max(100, 'Percentage must be between 0 and 100'), +}); + +// Venue Creation Validation +export const createVenueSchema = z + .object({ + // Basic Info + name: z.string().trim().min(3, 'Name must be at least 3 characters').max(100), + description: z.string().trim().min(10, 'Description must be at least 10 characters'), + venueType: z.string().trim().min(1, 'Venue type is required'), + + // Location + address: z.string().trim().min(5, 'Address must be at least 5 characters'), + city: z.string().trim().min(2).max(100), + district: z.enum(KERALA_DISTRICTS), + pincode: z.string().trim().min(4).max(20), + coordinates: coordinatesSchema.optional(), + googleMapsUrl: googleMapsUrlSchema, + + // Space & Capacity + spaceAttributes: z.array(z.string()).default([]), + seatingConfigurations: z.array(z.string()).default([]), + maxCapacity: z.coerce.number().int().positive().optional(), + + // Booking & Pricing Config + bookingType: z.enum(['fixedBooking', 'flexibleBooking']), + + // Fixed booking fields + fixedPackages: z.array(fixedPackageSchema).optional(), + + // Both booking types + workingDays: z.array(z.enum(DAYS_OF_WEEK)).min(1, 'At least one working day is required'), + + // Flexible booking fields + workingHours: z + .object({ + open: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Invalid time format (HH:MM)'), + close: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Invalid time format (HH:MM)'), + }) + .optional(), + flexibleBooking: z + .object({ + slotDuration: z.coerce.number().int().positive().default(60), + bufferTime: z.coerce.number().int().min(0).default(0), + }) + .optional(), + pricing: z + .object({ + pricingType: z.enum(['fixedPricing', 'timeBasedPricing']), + basePrice: z.coerce.number().min(0, 'Base price must be 0 or higher'), + pricingRules: z.array(pricingRuleSchema).default([]), + }) + .optional(), + blockedTimes: z.array(blockedTimeSchema).default([]), + blockedDates: z.array(z.coerce.date()).default([]), + + // Amenities + amenities: z.array(z.string()).min(1, 'At least one amenity is required'), + + // Media + coverImage: urlSchema, + galleryImages: z.array(urlSchema).max(20).optional(), + + // Contact + contact: z.object({ + name: z.string().trim().min(2).max(100), + phone: phoneSchema, + email: z + .string() + .regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Must be a valid email') + .optional(), + }), + + // Cancellation & Refund + cancellation: z.object({ + policy: z.enum(['refundable', 'nonRefundable']), + refundType: z.enum(['fullRefund', 'timeBasedRefund']).optional(), + refundRules: z.array(refundRuleSchema).default([]), + }), + }) + .superRefine((data, ctx) => { + // Helper: HH:MM → minutes since midnight + const toMin = (t: string): number => { + const [h, m] = t.split(':').map(Number); + return h * 60 + m; + }; + + // Fixed booking: must have at least one package + if (data.bookingType === 'fixedBooking') { + if (!data.fixedPackages || data.fixedPackages.length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'At least one fixed package is required for fixed booking', + path: ['fixedPackages'], + }); + } + } + + // Flexible booking: requires workingHours, flexibleBooking, and pricing + if (data.bookingType === 'flexibleBooking') { + if (!data.pricing) { + ctx.addIssue({ + code: 'custom', + message: 'Pricing is required for flexible booking', + path: ['pricing'], + }); + return; + } + if (!data.workingHours) { + ctx.addIssue({ + code: 'custom', + message: 'Working hours are required for flexible booking', + path: ['workingHours'], + }); + } else { + if (!data.workingHours.open) { + ctx.addIssue({ + code: 'custom', + message: 'Open time is required for flexible booking', + path: ['workingHours', 'open'], + }); + } + if (!data.workingHours.close) { + ctx.addIssue({ + code: 'custom', + message: 'Close time is required for flexible booking', + path: ['workingHours', 'close'], + }); + } + // close must be after open + if (data.workingHours.open && data.workingHours.close) { + if (toMin(data.workingHours.close) <= toMin(data.workingHours.open)) { + ctx.addIssue({ + code: 'custom', + message: 'Close time must be after open time', + path: ['workingHours', 'close'], + }); + } + } + } + + if (!data.flexibleBooking?.slotDuration) { + ctx.addIssue({ + code: 'custom', + message: 'Slot duration is required for flexible booking', + path: ['flexibleBooking', 'slotDuration'], + }); + } + + // Time-based pricing: must have at least one pricing rule + if ( + data.pricing.pricingType === 'timeBasedPricing' && + data.pricing.pricingRules.length === 0 + ) { + ctx.addIssue({ + code: 'custom', + message: 'At least one pricing rule is required for time-based pricing', + path: ['pricing', 'pricingRules'], + }); + } + + // Working-hours boundary checks for pricing rules + const wh = data.workingHours; + const openMin = wh?.open ? toMin(wh.open) : null; + const closeMin = wh?.close ? toMin(wh.close) : null; + + if (openMin !== null && closeMin !== null && wh) { + data.pricing.pricingRules.forEach((rule, i) => { + if (rule.fromTime) { + const from = toMin(rule.fromTime); + if (from < openMin) { + ctx.addIssue({ + code: 'custom', + message: `From time must be on or after open time (${wh.open})`, + path: ['pricing', 'pricingRules', i, 'fromTime'], + }); + } + } + if (rule.toTime) { + const to = toMin(rule.toTime); + if (to > closeMin) { + ctx.addIssue({ + code: 'custom', + message: `To time must be on or before close time (${wh.close})`, + path: ['pricing', 'pricingRules', i, 'toTime'], + }); + } + } + if (rule.fromTime && rule.toTime && toMin(rule.toTime) <= toMin(rule.fromTime)) { + ctx.addIssue({ + code: 'custom', + message: 'To time must be after from time', + path: ['pricing', 'pricingRules', i, 'toTime'], + }); + } + }); + + // Validate blockedTimes stay within working hours + data.blockedTimes.forEach((block, i) => { + if (block.fromTime) { + const from = toMin(block.fromTime); + if (from < openMin) { + ctx.addIssue({ + code: 'custom', + message: `From time must be on or after open time (${wh.open})`, + path: ['blockedTimes', i, 'fromTime'], + }); + } + } + if (block.toTime) { + const to = toMin(block.toTime); + if (to > closeMin) { + ctx.addIssue({ + code: 'custom', + message: `To time must be on or before close time (${wh.close})`, + path: ['blockedTimes', i, 'toTime'], + }); + } + } + if (block.fromTime && block.toTime && toMin(block.toTime) <= toMin(block.fromTime)) { + ctx.addIssue({ + code: 'custom', + message: 'To time must be after from time', + path: ['blockedTimes', i, 'toTime'], + }); + } + }); + } + } + + // Time-based refund: must have at least one refund rule + if ( + data.cancellation.policy === 'refundable' && + data.cancellation.refundType === 'timeBasedRefund' && + data.cancellation.refundRules.length === 0 + ) { + ctx.addIssue({ + code: 'custom', + message: 'At least one refund rule is required for time-based refund policy', + path: ['cancellation', 'refundRules'], + }); + } + }); + +export type CreateVenueDTO = z.infer; + +// Update +// PUT /venues/:id +export const updateVenueSchema = z + .object({ + expectedVersion: z.number().int().optional(), + name: z.string().trim().min(3, 'Name must be at least 3 characters').max(100), + description: z.string().trim().min(10, 'Description must be at least 10 characters'), + venueType: z.string().trim().min(1, 'Venue type is required'), + address: z.string().trim().min(5, 'Address must be at least 5 characters'), + city: z.string().trim().min(2).max(100), + district: z.string().trim().min(2).max(100), + pincode: z.string().trim().min(4).max(20), + coordinates: coordinatesSchema.optional(), + googleMapsUrl: googleMapsUrlSchema, + spaceAttributes: z.array(z.string()), + seatingConfigurations: z.array(z.string()), + maxCapacity: z.coerce.number().int().positive().optional(), + bookingType: z.enum(['fixedBooking', 'flexibleBooking']), + fixedPackages: z.array(fixedPackageSchema), + workingDays: z.array(z.enum(DAYS_OF_WEEK)).min(1, 'At least one working day is required'), + workingHours: z.object({ + open: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/), + close: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/), + }), + flexibleBooking: z.object({ + slotDuration: z.coerce.number().int().positive(), + bufferTime: z.coerce.number().int().min(0), + }), + pricing: z + .object({ + pricingType: z.enum(['fixedPricing', 'timeBasedPricing']), + basePrice: z.coerce.number().min(0), + pricingRules: z.array(pricingRuleSchema).default([]), + }) + .optional(), + blockedTimes: z.array(blockedTimeSchema), + blockedDates: z.array(z.coerce.date()), + amenities: z.array(z.string()), + coverImage: urlSchema, + galleryImages: z.array(urlSchema).max(20).optional(), + contact: z.object({ + name: z.string().trim().min(2).max(100), + phone: phoneSchema, + email: z + .string() + .regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Must be a valid email') + .optional(), + }), + cancellation: z.object({ + policy: z.enum(['refundable', 'nonRefundable']), + refundType: z.enum(['fullRefund', 'timeBasedRefund']).optional(), + refundRules: z.array(refundRuleSchema).default([]), + }), + }) + .partial() + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one field must be provided for update', + }); + +export type UpdateVenueDTO = z.infer; + +// Reject venue with optional extended deadline +export const rejectVenueSchema = z.object({ + rejectionReason: z + .string() + .trim() + .max(500, 'Rejection reason must be under 500 characters') + .optional(), + extendedDeadline: z.coerce + .date() + .optional() + .refine( + (date) => + !date || + (date > new Date(Date.now() + VENUE_CONSTANTS.EDIT_WINDOW_DAYS * 24 * 60 * 60 * 1000) && + date <= new Date(Date.now() + VENUE_CONSTANTS.MAX_EXTENDED_DAYS * 24 * 60 * 60 * 1000)), + `Extended deadline must be between ${String(VENUE_CONSTANTS.EDIT_WINDOW_DAYS)} and ${String(VENUE_CONSTANTS.MAX_EXTENDED_DAYS)} days` + ), +}); + +export type RejectVenueDTO = z.infer; + +// Extend venue deadline +export const extendVenueDeadlineSchema = z.object({ + newDeadline: z.coerce + .date() + .refine( + (date) => + date > new Date() && + date <= new Date(Date.now() + VENUE_CONSTANTS.MAX_EXTENDED_DAYS * 24 * 60 * 60 * 1000), + `New deadline must be in the future and within ${String(VENUE_CONSTANTS.MAX_EXTENDED_DAYS)} days` + ), +}); + +export type ExtendVenueDeadlineDTO = z.infer; + +export const suspendVenueSchema = z.object({ + suspensionReason: z + .string() + .trim() + .min(10, 'Suspension reason must be at least 10 characters') + .max(500, 'Suspension reason must be under 500 characters'), +}); + +export type SuspendVenueDTO = z.infer; + +export const featureVenueSchema = z.object({ + durationDays: z.enum(['7', '30', 'indefinite']), +}); + +export type FeatureVenueDTO = z.infer; + +// Admin list filters + +export const adminVenueFiltersSchema = z.object({ + status: z.enum(['Draft', 'PendingReview', 'Approved', 'Rejected', 'Suspended']).optional(), + city: z.string().trim().max(100).optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +export type AdminVenueFiltersDTO = z.infer; + +const stringOrArray = z.preprocess( + (val) => (Array.isArray(val) ? val : val ? [val] : undefined), + z.array(z.string()).optional() +); + +export const publicVenueFiltersSchema = z + .object({ + searchTerm: z.string().trim().max(100).optional(), + minPrice: z.coerce.number().min(0).optional(), + maxPrice: z.coerce.number().min(0).optional(), + venueType: stringOrArray, + district: z.enum(KERALA_DISTRICTS).optional(), + capacity: z.coerce.number().int().positive().optional(), + spaceAttributes: stringOrArray, + seatingConfigurations: stringOrArray, + amenities: stringOrArray, + lat: z.coerce.number().min(-90).max(90).optional(), + lng: z.coerce.number().min(-180).max(180).optional(), + radiusKm: z.coerce.number().positive().max(200).default(25).optional(), + sortBy: z.enum(['price-low', 'price-high', 'rating', 'distance']).optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), + }) + .refine( + (data) => { + // If lat is provided, lng must also be provided + if (data.lat !== undefined && data.lng === undefined) return false; + // If lng is provided, lat must also be provided + if (data.lng !== undefined && data.lat === undefined) return false; + return true; + }, + { + message: 'Both latitude and longitude must be provided together', + path: ['lat', 'lng'], + } + ); + +export type PublicVenueFiltersDTO = z.infer; + +// Review schemas + +export const approveReviewSchema = z.object({ + note: z.string().trim().max(500).optional(), +}); + +export type ApproveReviewDTO = z.infer; + +export const rejectReviewSchema = z.object({ + note: z.string().trim().min(1).max(500), +}); + +export type RejectReviewDTO = z.infer; + +// Route param + +export const venueIdParamSchema = z.object({ + id: venueIdSchema, +}); + +export type VenueIdParamDTO = z.infer; diff --git a/server/src/modules/venue/venue.workflow.ts b/server/src/modules/venue/venue.workflow.ts new file mode 100644 index 0000000000..6c614dc521 --- /dev/null +++ b/server/src/modules/venue/venue.workflow.ts @@ -0,0 +1,218 @@ +import type { IVenue, VenueStatus } from './venue.types'; +import { + INACTIVITY_COOLDOWN_DAYS, + SUBMISSION_REQUIRED_FIELDS, +} from '../../constants/venue.constants'; +import { WorkflowError, ValidationError } from '../../utils/errors'; +import { timeStringToMinutes } from '../../utils/timeUtils'; + +/** + * Venue Status State Machine + * + * Legal transitions: + * + * Draft → PendingReview (owner submits) + * PendingReview → Approved (admin approves) + * PendingReview → Rejected (admin rejects) + * Approved → Suspended (admin deactivates) + * Approved → Inactive (owner requests inactivity) + * Rejected → PendingReview (owner re-submits after editing) + * Rejected → Suspended (admin deactivates) + * Suspended → Approved (admin reactivates) + * Inactive → Approved (owner or admin reactivates) + */ + +type TransitionMap = Partial>; + +const ALLOWED_TRANSITIONS: TransitionMap = { + Draft: ['PendingReview'], + PendingReview: ['Approved', 'Rejected'], + Approved: ['Suspended', 'Inactive'], + Rejected: ['PendingReview', 'Suspended'], + Suspended: ['Approved'], + Inactive: ['Approved'], +}; + +// Core Guard +export function assertTransition(venue: IVenue, targetStatus: VenueStatus): void { + const allowed = ALLOWED_TRANSITIONS[venue.status] ?? []; + if (!allowed.includes(targetStatus)) { + throw new WorkflowError(venue.status, `transition to '${targetStatus}'`); + } +} + +// Named Transition Guards +export function canSubmit(venue: IVenue): void { + assertTransition(venue, 'PendingReview'); + + const venueRecord = venue as unknown as Record; + const missingFields = SUBMISSION_REQUIRED_FIELDS.filter((field) => { + const value = venueRecord[field as string]; + if (value === null || value === undefined) return true; + if (typeof value === 'string' && value.trim() === '') return true; + return false; + }); + + if (missingFields.length > 0) { + throw new ValidationError( + `Cannot submit: the following required fields are missing or empty — ${missingFields.join(', ')}` + ); + } + + if (venue.workingDays.length === 0) { + throw new ValidationError('Cannot submit: at least one working day must be selected'); + } + + if (venue.amenities.length === 0) { + throw new ValidationError('Cannot submit: at least one amenity is required'); + } + + // Fixed booking checks + if (venue.bookingType === 'fixedBooking') { + if (!venue.fixedPackages || venue.fixedPackages.length === 0) { + throw new ValidationError( + 'Cannot submit: at least one fixed package is required for fixed booking type' + ); + } + venue.fixedPackages.forEach((pkg) => { + if (timeStringToMinutes(pkg.endTime) <= timeStringToMinutes(pkg.startTime)) { + throw new ValidationError( + `Cannot submit: fixed package "${pkg.slotName}" must have endTime after startTime` + ); + } + }); + } + + // Flexible booking checks + if (venue.bookingType === 'flexibleBooking') { + if (!venue.workingHours?.open || !venue.workingHours.close) { + throw new ValidationError( + 'Cannot submit: working hours (open & close) are required for flexible booking type' + ); + } + if (!venue.flexibleBooking?.slotDuration) { + throw new ValidationError( + 'Cannot submit: slot duration is required for flexible booking type' + ); + } + + // Pricing checks inside flexible booking + if (!venue.pricing) { + throw new ValidationError('Cannot submit: pricing is required for flexible booking type'); + } + if ( + venue.pricing.pricingType === 'timeBasedPricing' && + venue.pricing.pricingRules.length === 0 + ) { + throw new ValidationError( + 'Cannot submit: at least one pricing rule is required for time-based pricing' + ); + } + + const openMin = timeStringToMinutes(venue.workingHours.open); + const closeMin = timeStringToMinutes(venue.workingHours.close); + + venue.pricing.pricingRules.forEach((rule) => { + const from = timeStringToMinutes(rule.fromTime); + const to = timeStringToMinutes(rule.toTime); + if (to <= from) { + throw new ValidationError('Cannot submit: pricing rule toTime must be after fromTime'); + } + if (from < openMin || to > closeMin) { + throw new ValidationError('Cannot submit: pricing rule times must be within working hours'); + } + }); + + (venue.blockedTimes ?? []).forEach((block) => { + const from = timeStringToMinutes(block.fromTime); + const to = timeStringToMinutes(block.toTime); + if (to <= from) { + throw new ValidationError('Cannot submit: blocked time toTime must be after fromTime'); + } + if (from < openMin || to > closeMin) { + throw new ValidationError('Cannot submit: blocked time must be within working hours'); + } + }); + } + + // Refund policy checks + if (venue.cancellation.policy === 'refundable') { + if (!venue.cancellation.refundType) { + throw new ValidationError( + 'Cannot submit: refund type is required for refundable cancellation policy' + ); + } + if ( + venue.cancellation.refundType === 'timeBasedRefund' && + venue.cancellation.refundRules.length === 0 + ) { + throw new ValidationError( + 'Cannot submit: at least one refund rule is required for time-based refund policy' + ); + } + } +} + +export function canApprove(venue: IVenue): void { + assertTransition(venue, 'Approved'); +} + +export function canReject(venue: IVenue): void { + assertTransition(venue, 'Rejected'); +} + +export function canDeactivate(venue: IVenue): void { + assertTransition(venue, 'Suspended'); +} + +export function canActivate(venue: IVenue): void { + assertTransition(venue, 'Approved'); +} + +export function canEdit(venue: IVenue): void { + const editableStatuses: VenueStatus[] = ['Draft', 'Rejected']; + if (!editableStatuses.includes(venue.status)) { + throw new WorkflowError( + venue.status, + 'edit -- only Draft or Rejected venues can be directly edited' + ); + } +} + +export function canDelete(venue: IVenue): void { + const deletableStatuses: VenueStatus[] = ['Draft', 'Rejected']; + if (!deletableStatuses.includes(venue.status)) { + throw new WorkflowError( + venue.status, + 'delete -- only Draft or Rejected venues can be directly deleted' + ); + } +} + +export function canReactivate(venue: IVenue): void { + if (venue.status !== 'Inactive') { + throw new WorkflowError(venue.status, 'reactivate -- only Inactive venues can be reactivated'); + } +} + +export function canRequestInactivity(venue: IVenue): void { + if (venue.status !== 'Approved') { + throw new WorkflowError(venue.status, 'inactivity request -- only Approved venues'); + } + + if (venue.inactivity?.lastInactiveAt) { + const cooldownEnd = new Date(venue.inactivity.lastInactiveAt); + cooldownEnd.setDate(cooldownEnd.getDate() + INACTIVITY_COOLDOWN_DAYS); + if (new Date() < cooldownEnd) { + throw new ValidationError( + `Inactivity cooldown active. You can request again after ${cooldownEnd.toISOString().split('T')[0]}.` + ); + } + } +} + +export function canRequestDelete(venue: IVenue): void { + if (venue.deleted) { + throw new WorkflowError('Deleted', 'delete request -- venue already deleted'); + } +} diff --git a/server/src/modules/venue/venueDraft.model.ts b/server/src/modules/venue/venueDraft.model.ts new file mode 100644 index 0000000000..0a4a0c9756 --- /dev/null +++ b/server/src/modules/venue/venueDraft.model.ts @@ -0,0 +1,35 @@ +import mongoose, { type Document, Schema } from 'mongoose'; + +export interface IVenueDraft extends Document { + userId: mongoose.Types.ObjectId; + step: number; + formValues: Record; + createdAt: Date; + updatedAt: Date; +} + +const venueDraftSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + unique: true, // Only one draft per user + }, + step: { + type: Number, + required: true, + default: 0, + }, + formValues: { + type: Schema.Types.Mixed, + required: true, + default: {}, + }, + }, + { + timestamps: true, // Automatically manage createdAt and updatedAt + } +); + +export const VenueDraftModel = mongoose.model('VenueDraft', venueDraftSchema); diff --git a/server/src/modules/webhook/webhook.controller.ts b/server/src/modules/webhook/webhook.controller.ts new file mode 100644 index 0000000000..a32aa24f7b --- /dev/null +++ b/server/src/modules/webhook/webhook.controller.ts @@ -0,0 +1,73 @@ +import type { Request, Response } from 'express'; +import { logError, logWarn } from '../../utils/logger'; +import { verifyWebhookSignature } from '../../services/razorpay.service'; +import { processCapturedPayment } from '../booking/booking.service'; +import type { RazorpayWebhookPayload } from './webhook.types'; + +// Handles incoming Razorpay webhook events +export const handleRazorpayWebhook = async (req: Request, res: Response): Promise => { + const rawBody = req.body as Buffer; + const signature = req.headers['x-razorpay-signature']; + + if (!signature || typeof signature !== 'string') { + logWarn('Razorpay webhook received without signature header', { + module: 'webhook.controller.ts/handleRazorpayWebhook', + }); + res.status(200).json({ received: false, error: 'Missing x-razorpay-signature header' }); + return; + } + + const isValid = verifyWebhookSignature(rawBody, signature); + + if (!isValid) { + logWarn('Razorpay webhook signature verification failed — rejecting', { + module: 'webhook.controller.ts/handleRazorpayWebhook', + }); + res.status(200).json({ received: false, error: 'Invalid webhook signature' }); + return; + } + + let webhookData: RazorpayWebhookPayload; + + try { + webhookData = JSON.parse(rawBody.toString('utf8')) as RazorpayWebhookPayload; + } catch (err) { + logError('Failed to parse webhook body as JSON', { + module: 'webhook.controller.ts/handleRazorpayWebhook', + error: (err as Error).message, + }); + res.status(400).json({ error: 'Invalid JSON payload' }); + return; + } + + // Only process payment.captured events. Return 200 for other events to acknowledge. + if (webhookData.event !== 'payment.captured') { + res.status(200).json({ received: true }); + return; + } + + const payment = webhookData.payload?.payment?.entity; + + if (!payment) { + logWarn('Webhook payment.captured payload missing entity', { + module: 'webhook.controller.ts/handleRazorpayWebhook', + }); + res.status(200).json({ received: true }); + return; + } + + // Process the captured payment in the booking service + const result = await processCapturedPayment( + payment.id, + payment.amount, + payment.notes, + payment.method + ); + + if (!result.success) { + res.status(200).json({ received: true, error: result.error }); + return; + } + + res.status(200).json({ received: true, idempotent: result.isDuplicate }); +}; diff --git a/server/src/modules/webhook/webhook.router.ts b/server/src/modules/webhook/webhook.router.ts new file mode 100644 index 0000000000..2bc08d9c56 --- /dev/null +++ b/server/src/modules/webhook/webhook.router.ts @@ -0,0 +1,67 @@ +/** + * webhook.router.ts + * + * CRITICAL: This router applies `express.raw({ type: 'application/json' })` + * as route-level middleware on the Razorpay webhook endpoint. + * + * This MUST be registered in server.ts BEFORE the global `express.json()` + * middleware. Express matches routes in registration order — if express.json() + * runs first, it consumes the stream and the raw Buffer is lost, breaking + * the HMAC signature verification. + * + * The route has NO auth middleware. Razorpay sends webhooks from their servers; + * the HMAC-SHA256 signature check in the controller IS the authentication. + */ + +import express, { Router } from 'express'; +import { handleRazorpayWebhook } from './webhook.controller'; + +const router: Router = Router(); + +/** + * @openapi + * /webhook/razorpay: + * post: + * tags: [Webhooks] + * summary: Receive a Razorpay payment event + * description: | + * Ingests Razorpay webhook payloads and verifies the HMAC-SHA256 signature. + * **No JWT auth** — Razorpay server-to-server calls are authenticated by the + * `x-razorpay-signature` header and the shared webhook secret. + * This route must receive the **raw request body** (not parsed JSON) so the + * HMAC can be computed correctly. + * parameters: + * - in: header + * name: x-razorpay-signature + * required: true + * schema: + * type: string + * description: HMAC-SHA256 signature computed by Razorpay over the raw payload + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * description: Raw Razorpay webhook event payload + * properties: + * event: + * type: string + * example: payment.captured + * payload: + * type: object + * responses: + * 200: + * description: Webhook received and processed + * 400: + * description: Invalid or missing HMAC signature + * 500: + * description: Internal error while processing the event + */ +router.route('/razorpay').post( + // Capture raw bytes BEFORE any JSON parsing + express.raw({ type: 'application/json' }), + handleRazorpayWebhook +); + +export { router as webhookRouter }; diff --git a/server/src/modules/webhook/webhook.types.ts b/server/src/modules/webhook/webhook.types.ts new file mode 100644 index 0000000000..1e406fd916 --- /dev/null +++ b/server/src/modules/webhook/webhook.types.ts @@ -0,0 +1,19 @@ +import type { RazorpayWebhookNotes } from '../booking/booking.types'; + +export interface RazorpayPaymentEntity { + id: string; + order_id: string; + amount: number; + currency: string; + method?: string; + notes: RazorpayWebhookNotes; +} + +export interface RazorpayWebhookPayload { + event: string; + payload?: { + payment?: { + entity?: RazorpayPaymentEntity; + }; + }; +} diff --git a/server/src/modules/wishlist/wishlist.controller.ts b/server/src/modules/wishlist/wishlist.controller.ts new file mode 100644 index 0000000000..5163924491 --- /dev/null +++ b/server/src/modules/wishlist/wishlist.controller.ts @@ -0,0 +1,93 @@ +import type { Request, Response } from 'express'; +import { ResponseUtil } from '../../utils/responseUtils'; +import * as service from './wishlist.service'; +import { handleError } from '../../utils/errors'; + +export const toggleWishlist = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const venueId = Array.isArray(req.params.venueId) ? req.params.venueId[0] : req.params.venueId; + const result = await service.toggleWishlist(userId, venueId); + + ResponseUtil.success( + res, + result.wishlisted ? 'Added to wishlist' : 'Removed from wishlist', + result + ); + } catch (err) { + handleError(res, err, 'toggleWishlist'); + } +}; + +export const syncWishlist = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const { venueIds } = req.validated?.body as { venueIds: string[] }; + const status = await service.syncWishlist(userId, venueIds); + + ResponseUtil.success(res, 'Wishlist synced successfully', status); + } catch (err) { + handleError(res, err, 'syncWishlist'); + } +}; + +export const getMyWishlist = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const paginationParams = req.pagination ?? { page: 1, limit: 20, skip: 0, sort: '' }; + const result = await service.getMyWishlist(userId, paginationParams); + + ResponseUtil.paginated( + res, + 'Wishlist retrieved successfully', + result.venues, + result.pagination, + 'venues' + ); + } catch (err) { + handleError(res, err, 'getMyWishlist'); + } +}; + +export const getWishlistStatus = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + if (!userId) { + ResponseUtil.unauthorized(res, 'Unauthorized'); + return; + } + + const { venueIds } = req.query; + const venueIdArray = Array.isArray(venueIds) + ? (venueIds as string[]) + : typeof venueIds === 'string' + ? venueIds.split(',') + : []; + + // Cap at 100 IDs to prevent abuse + if (venueIdArray.length > 100) { + ResponseUtil.badRequest(res, 'Maximum 100 venue IDs allowed'); + return; + } + + const status = await service.getWishlistStatus(userId, venueIdArray); + ResponseUtil.success(res, 'Wishlist status retrieved', status); + } catch (err) { + handleError(res, err, 'getWishlistStatus'); + } +}; diff --git a/server/src/modules/wishlist/wishlist.model.ts b/server/src/modules/wishlist/wishlist.model.ts new file mode 100644 index 0000000000..3bd19c27b0 --- /dev/null +++ b/server/src/modules/wishlist/wishlist.model.ts @@ -0,0 +1,43 @@ +import mongoose, { Schema } from 'mongoose'; +import type { Document } from 'mongoose'; + +export interface IWishlistItem extends Document { + userId: mongoose.Types.ObjectId; + venueId: mongoose.Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const WishlistSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: 'Users', + required: true, + }, + venueId: { + type: Schema.Types.ObjectId, + ref: 'Venues', + required: true, + }, + }, + { timestamps: true } +); + +// Unique compound index: one wishlist entry per user-venue pair +WishlistSchema.index( + { userId: 1, venueId: 1 }, + { + unique: true, + name: 'idx_user_venue_unique', + } +); + +// Index for "My Wishlist" listing (user + most recent first) +WishlistSchema.index({ userId: 1, createdAt: -1 }, { name: 'idx_user_recency' }); + +export const WishlistModel = mongoose.model( + 'Wishlists', + WishlistSchema, + 'Wishlists' +); diff --git a/server/src/modules/wishlist/wishlist.repository.ts b/server/src/modules/wishlist/wishlist.repository.ts new file mode 100644 index 0000000000..2452660c37 --- /dev/null +++ b/server/src/modules/wishlist/wishlist.repository.ts @@ -0,0 +1,82 @@ +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import { buildPaginationMeta } from '../../utils/paginationUtils'; +import { WishlistModel, type IWishlistItem } from './wishlist.model'; +import { VenueModel } from '../venue/venue.model'; +import type { IVenue } from '../venue/venue.types'; +import mongoose from 'mongoose'; + +const toObjectId = (id: string): mongoose.Types.ObjectId => { + return new mongoose.Types.ObjectId(id); +}; + +export async function addToWishlist(userId: string, venueId: string): Promise { + const result = await WishlistModel.findOneAndUpdate( + { userId: toObjectId(userId), venueId: toObjectId(venueId) }, + { userId: toObjectId(userId), venueId: toObjectId(venueId) }, + { upsert: true, new: true } + ).exec(); + + return result; +} + +export async function removeFromWishlist(userId: string, venueId: string): Promise { + await WishlistModel.deleteOne({ + userId: toObjectId(userId), + venueId: toObjectId(venueId), + }).exec(); +} + +export async function isWishlisted(userId: string, venueId: string): Promise { + const result = await WishlistModel.exists({ + userId: toObjectId(userId), + venueId: toObjectId(venueId), + }).exec(); + + return !!result; +} + +export async function findUserWishlist( + userId: string, + paginationParams: PaginationParams +): Promise> { + const { limit, skip } = paginationParams; + + const [venues, total] = await Promise.all([ + WishlistModel.find({ userId: toObjectId(userId) }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .populate({ + path: 'venueId', + model: VenueModel, + match: { active: true, deleted: false, status: 'Approved' }, + select: + '_id name description venueType city district coverImage maxCapacity avgRating reviewCount flexibleBooking amenities pricing fixedPackages bookingType', + }) + .lean() + .exec() + .then((items) => items.map((item) => item.venueId as unknown as IVenue).filter(Boolean)), + + WishlistModel.countDocuments({ userId: toObjectId(userId) }).exec(), + ]); + + return { + venues, + pagination: buildPaginationMeta(total, paginationParams), + }; +} + +export async function getWishlistedVenueIds( + userId: string, + venueIds: string[] +): Promise> { + const wishlisted = await WishlistModel.find({ + userId: toObjectId(userId), + venueId: { $in: venueIds.map(toObjectId) }, + }) + .select('venueId') + .lean() + .exec(); + + return new Set(wishlisted.map((item) => item.venueId.toString())); +} diff --git a/server/src/modules/wishlist/wishlist.router.ts b/server/src/modules/wishlist/wishlist.router.ts new file mode 100644 index 0000000000..996e38fe54 --- /dev/null +++ b/server/src/modules/wishlist/wishlist.router.ts @@ -0,0 +1,131 @@ +import { Router } from 'express'; +import { verifyAccessToken } from '../../middlewares/auth.middleware'; +import { requirePermission } from '../../middlewares/rbac.middleware'; +import { PERMISSIONS as P } from '../../constants/permissions'; +import { paginationMiddleware } from '../../middlewares/pagination.middleware'; +import { validateBody } from '../../middlewares/validation.middleware'; +import * as validator from './wishlist.validator'; +import * as controller from './wishlist.controller'; + +const router: Router = Router(); + +/** + * @openapi + * /wishlist/toggle/{venueId}: + * post: + * tags: [Wishlist] + * summary: Toggle a venue in user's wishlist + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: venueId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Wishlist toggled successfully + * 401: + * description: Not authenticated + * 403: + * description: Insufficient permissions + * 404: + * description: Venue not found + */ +router + .route('/toggle/:venueId') + .post(verifyAccessToken, requirePermission(P.wishlist.create), controller.toggleWishlist); + +/** + * @openapi + * /wishlist: + * get: + * tags: [Wishlist] + * summary: Get user's wishlist (paginated) + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Wishlist retrieved successfully + * 401: + * description: Not authenticated + */ +router + .route('/') + .get( + verifyAccessToken, + requirePermission(P.wishlist.read), + paginationMiddleware(), + controller.getMyWishlist + ); + +/** + * @openapi + * /wishlist/status: + * get: + * tags: [Wishlist] + * summary: Check wishlist status for multiple venues + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: venueIds + * schema: + * type: string + * description: Comma-separated venue IDs (max 100) + * responses: + * 200: + * description: Wishlist statuses for requested venues + * 401: + * description: Not authenticated + */ +router + .route('/status') + .get(verifyAccessToken, requirePermission(P.wishlist.read), controller.getWishlistStatus); + +/** + * @openapi + * /wishlist/sync: + * post: + * tags: [Wishlist] + * summary: Merge a guest's locally-stored wishlist into the user's account + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [venueIds] + * properties: + * venueIds: + * type: array + * items: + * type: string + * responses: + * 200: + * description: Wishlist synced successfully + * 401: + * description: Not authenticated + */ +router + .route('/sync') + .post( + verifyAccessToken, + requirePermission(P.wishlist.create), + validateBody(validator.syncWishlistBodySchema), + controller.syncWishlist + ); + +export { router as wishlistRouter }; diff --git a/server/src/modules/wishlist/wishlist.service.ts b/server/src/modules/wishlist/wishlist.service.ts new file mode 100644 index 0000000000..dd6d0c6989 --- /dev/null +++ b/server/src/modules/wishlist/wishlist.service.ts @@ -0,0 +1,64 @@ +import * as repo from './wishlist.repository'; +import type { PaginationParams, PaginatedResponse } from '../../types/pagination.types'; +import type { IVenue } from '../venue/venue.types'; +import { venueExists, findVenuesByIds } from '../venue/venue.repository'; +import { NotFoundError } from '../../utils/errors'; +import type { WishlistResponse, WishlistStatusResponse } from './wishlist.types'; + +export async function toggleWishlist(userId: string, venueId: string): Promise { + // Verify venue exists + const venueExistsFlag = await venueExists(venueId); + if (!venueExistsFlag) { + throw new NotFoundError('Venue not found'); + } + + // Check if already wishlisted + const isCurrentlyWishlisted = await repo.isWishlisted(userId, venueId); + + if (isCurrentlyWishlisted) { + // Remove from wishlist + await repo.removeFromWishlist(userId, venueId); + return { wishlisted: false }; + } else { + // Add to wishlist + await repo.addToWishlist(userId, venueId); + return { wishlisted: true }; + } +} + +export async function syncWishlist( + userId: string, + venueIds: string[] +): Promise { + if (!venueIds.length) return {}; + + const existingVenues = await findVenuesByIds(venueIds); + const validVenueIds = existingVenues.map((v: { _id: { toString(): string } }) => + v._id.toString() + ); + + await Promise.all(validVenueIds.map((venueId: string) => repo.addToWishlist(userId, venueId))); + + return getWishlistStatus(userId, venueIds); +} + +export async function getMyWishlist( + userId: string, + paginationParams: PaginationParams +): Promise> { + return repo.findUserWishlist(userId, paginationParams); +} + +export async function getWishlistStatus( + userId: string, + venueIds: string[] +): Promise { + if (!venueIds.length) return {}; + + const wishlisted = await repo.getWishlistedVenueIds(userId, venueIds); + + return venueIds.reduce((acc, venueId) => { + acc[venueId] = wishlisted.has(venueId); + return acc; + }, {}); +} diff --git a/server/src/modules/wishlist/wishlist.types.ts b/server/src/modules/wishlist/wishlist.types.ts new file mode 100644 index 0000000000..8b5495a152 --- /dev/null +++ b/server/src/modules/wishlist/wishlist.types.ts @@ -0,0 +1,13 @@ +export interface IWishlistItem { + _id: string; + userId: string; + venueId: string; + createdAt: Date; + updatedAt: Date; +} + +export interface WishlistResponse { + wishlisted: boolean; +} + +export type WishlistStatusResponse = Record; diff --git a/server/src/modules/wishlist/wishlist.validator.ts b/server/src/modules/wishlist/wishlist.validator.ts new file mode 100644 index 0000000000..e07a068f68 --- /dev/null +++ b/server/src/modules/wishlist/wishlist.validator.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const syncWishlistBodySchema = z.object({ + venueIds: z.array(z.string().trim().min(1)).max(200), +}); diff --git a/server/src/router.ts b/server/src/router.ts new file mode 100644 index 0000000000..a1f641a57b --- /dev/null +++ b/server/src/router.ts @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import { ResponseUtil } from './utils/responseUtils'; +import { authRouter } from './modules/auth/auth.router'; +import { userRouter } from './modules/user/user.router'; +import { venueRouter } from './modules/venue/venue.router'; +import { bookingRouter } from './modules/booking/booking.router'; +import { rbacRouter } from './modules/rbac/rbac.router'; +import { availabilityRouter } from './modules/availability/availability.router'; +import { roleRouter } from './modules/role/role.router'; +import { swaggerRouter } from './modules/swagger/swagger.router'; +import ownerRouter from './modules/owner/owner.router'; +import { geoRouter } from './modules/geo/geo.router'; +import { wishlistRouter } from './modules/wishlist/wishlist.router'; +import { reviewRouter } from './modules/review/review.router'; +import { moderationRouter } from './modules/moderation/moderation.router'; + +const router: Router = Router(); + +/** + * @openapi + * /health: + * get: + * tags: [Health] + * summary: Server health check + * responses: + * 200: + * description: Service is healthy + */ +router.get('/health', (_req, res) => { + ResponseUtil.success(res, 'Service is healthy'); +}); + +router.use('/auth', authRouter); +router.use('/user', userRouter); +router.use('/venues', venueRouter); +router.use('/bookings', bookingRouter); +router.use('/availability', availabilityRouter); +router.use('/rbac', rbacRouter); +router.use('/role', roleRouter); +router.use('/swagger', swaggerRouter); +router.use('/owner', ownerRouter); +router.use('/geo', geoRouter); +router.use('/wishlist', wishlistRouter); +router.use('/reviews', reviewRouter); +router.use('/moderation', moderationRouter); + +export default router; diff --git a/server/src/server.ts b/server/src/server.ts new file mode 100644 index 0000000000..93fc35f1af --- /dev/null +++ b/server/src/server.ts @@ -0,0 +1,37 @@ +import 'dotenv/config'; +import type { Server } from 'http'; + +import { app } from './app'; +import { connectDatabase } from './configs/database.config'; +import { validateEnv } from './configs/envValidation.config'; +import { validateEmailConfig } from './services/email.service'; +import { startEmailWorker } from './workers/email.worker'; +import { startBanExpiryWorker } from './workers/banExpiry.worker'; +import { startAutoSuspendWorker } from './workers/venueEditDeadline.worker'; +import { startBookingStatusWorker } from './workers/bookingStatus.worker'; +import { setupGracefulShutdown } from './utils/shutdownUtils'; +import { logInfo } from './utils/logger'; +import { verifyRbacSeed } from './services/roles.service'; + +const PORT = parseInt(process.env.PORT ?? '3000', 10); + +let server: Server | null = null; + +// Start server +async function startServer(): Promise { + validateEnv(); + validateEmailConfig(); + await connectDatabase(); + await verifyRbacSeed(); + startEmailWorker(); + startBanExpiryWorker(); + startAutoSuspendWorker(); + startBookingStatusWorker(); + server = app.listen(PORT, '0.0.0.0', () => { + logInfo(`Server started on port ${String(PORT)}`); + }); + // Register shutdown handlers AFTER server is assigned so the getter returns the live instance + setupGracefulShutdown(() => server); +} + +void startServer(); diff --git a/server/src/services/cache/permission-cache.service.ts b/server/src/services/cache/permission-cache.service.ts new file mode 100644 index 0000000000..3b30f35da3 --- /dev/null +++ b/server/src/services/cache/permission-cache.service.ts @@ -0,0 +1,103 @@ +import type { IPermission } from '../../constants/permissions'; +import { fetchRolePermissions } from '../roles.service'; +import { logInfo } from '../../utils/logger'; + +interface CacheEntry { + permissions: string[]; + roleName: string; + timestamp: number; +} + +export interface CacheStatEntry { + roleId: string; + roleName: string; + permissionCount: number; + ageSeconds: number; +} + +const store = new Map(); + +const TTL_MS = 15 * 60 * 1000; // 15mins (same as accessToken TTL) +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5mins + +// Pure helpers +const isExpired = (entry: CacheEntry): boolean => Date.now() - entry.timestamp > TTL_MS; + +const ageInSeconds = (entry: CacheEntry): number => + Math.round((Date.now() - entry.timestamp) / 1000); + +// Internal read/write +const getEntry = (roleId: string): CacheEntry | null => { + const entry = store.get(roleId); + if (!entry) return null; + if (isExpired(entry)) { + store.delete(roleId); + return null; + } + return entry; +}; + +const setEntry = (roleId: string, roleName: string, permissions: string[]): void => { + store.set(roleId, { permissions, roleName, timestamp: Date.now() }); +}; + +const resolveAndCache = async (roleId: string, roleName: string): Promise => { + const permissions = await fetchRolePermissions(roleId); + setEntry(roleId, roleName, permissions); + return permissions; +}; + +// Public API + +/** + * Cache-first permission lookup. + * Cache hit → returns in-memory set (no DB). + * Cache miss → runs $graphLookup aggregation, stores result, returns it. + */ +export const getPerms = async (roleId: string, roleName: string): Promise => { + const cached = getEntry(roleId); + const permissions = cached ? cached.permissions : await resolveAndCache(roleId, roleName); + return permissions as IPermission[]; +}; + +// Invalidate a single role's cache entry. +export const invalidateRole = (roleId: string): void => { + const existed = store.delete(roleId); + if (existed) { + logInfo('Permission cache invalidated', { roleId }); + } +}; + +// Clear the entire cache. +// Use after bulk permission changes or schema migrations. +export const clearAll = (): void => { + const size = store.size; + store.clear(); + logInfo('Permission cache cleared', { removed: size }); +}; + +export const getStats = (): { size: number; entries: CacheStatEntry[] } => { + const entries = Array.from(store.entries()).map(([roleId, entry]) => ({ + roleId, + roleName: entry.roleName, + permissionCount: entry.permissions.length, + ageSeconds: ageInSeconds(entry), + })); + return { size: store.size, entries }; +}; + +// Background cleanup +const cleanupExpired = (): void => { + let removed = 0; + for (const [roleId, entry] of store.entries()) { + if (isExpired(entry)) { + store.delete(roleId); + removed++; + } + } + if (removed > 0) { + logInfo('Permission cache cleanup', { removed }); + } +}; + +setInterval(cleanupExpired, CLEANUP_INTERVAL_MS).unref(); diff --git a/server/src/services/email.repository.ts b/server/src/services/email.repository.ts new file mode 100644 index 0000000000..e89cf6a721 --- /dev/null +++ b/server/src/services/email.repository.ts @@ -0,0 +1,22 @@ +import { EmailTaskModel } from '../models/email-task.model'; +import type { EmailIntentType, EmailTaskStatusType } from '../constants/email.constants'; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +export async function enqueueEmailTask( + recipient: string, + intent: EmailIntentType, + subject: string, + status: EmailTaskStatusType, + metadata: Record +): Promise { + await EmailTaskModel.create({ + recipient, + intent, + subject, + status, + retryAfter: new Date(), + metadata, + deleteAt: new Date(Date.now() + SEVEN_DAYS_MS), + }); +} diff --git a/server/src/services/email.service.ts b/server/src/services/email.service.ts new file mode 100644 index 0000000000..686bd603d6 --- /dev/null +++ b/server/src/services/email.service.ts @@ -0,0 +1,384 @@ +import { Resend } from 'resend'; +import { z } from 'zod'; +import { EmailIntent, type EmailIntentType } from '../constants/email.constants'; +import { resendConfig } from '../constants/env'; +import { logError, logInfo } from '../utils/logger'; +import { + getPasswordResetTemplate, + getPasswordChangedTemplate, + getBookingConfirmationTemplate, + getRefundNotificationTemplate, + getBookingCancellationTemplate, + getVenueApprovedTemplate, + getVenueRejectedTemplate, + getVenueSuspendedTemplate, + getVenueUnsuspendedTemplate, + getVenueDeadlineExtendedTemplate, + getAdminPasswordResetTemplate, + getUserBannedTemplate, + getUserUnbannedTemplate, + getReviewRemovedTemplate, + getReviewRestoredTemplate, +} from './emailTemplateFactory'; + +// Validates required email configuration on startup. +export function validateEmailConfig(): void { + const missing: string[] = []; + + if (!resendConfig.apiKey) missing.push('RESEND_API_KEY'); + if (!resendConfig.fromName) missing.push('EMAIL_FROM_NAME'); + if (!resendConfig.fromEmail) missing.push('EMAIL_FROM_EMAIL'); + if (!resendConfig.devToEmail) missing.push('RESEND_DEV_EMAIL_ADDRESS'); + if (!resendConfig.frontendUrl) missing.push('FRONTEND_URL'); + + if (missing.length > 0) { + logError('Missing required email environment variables', { + module: 'email.service.ts/validateEmailConfig', + missing, + }); + process.exit(1); + } + + logInfo('Email config validated'); +} + +let _resend: Resend | null = null; + +function getResendClient(): Resend { + if (!_resend) { + if (!resendConfig.apiKey) { + throw new Error('RESEND_API_KEY is not set'); + } + _resend = new Resend(resendConfig.apiKey); + } + return _resend; +} + +export interface SendEmailResult { + success: boolean; + messageId?: string; +} + +// Email input validation +const emailAddressSchema = z.email('Invalid recipient email address'); + +function validateRecipient(email: string): string { + if (process.env.NODE_ENV === 'development') { + const res = resendConfig.devToEmail ?? ''; + if (!emailAddressSchema.safeParse(res).success) { + throw new Error('Invalid development recipient email address'); + } + return res; + } + return emailAddressSchema.parse(email); +} + +// Core send helper +async function sendEmail( + to: string, + subject: string, + html: string, + intent: EmailIntentType +): Promise { + const from = `${resendConfig.fromName ?? ''} <${resendConfig.fromEmail ?? ''}>`; + + try { + const resend = getResendClient(); + const { data, error } = await resend.emails.send({ from, to, subject, html }); + + if (error) { + logError('Email delivery failed', { + module: 'email.service.ts/sendEmail', + intent, + recipient: to, + providerError: error.message, + }); + return { success: false }; + } + + logInfo('Email sent successfully', { + intent, + recipient: to, + messageId: data.id, + }); + + return { success: true, messageId: data.id }; + } catch (e) { + const err = e as Error; + logError('Email service encountered an unexpected error', { + module: 'email.service.ts/sendEmail', + intent, + recipient: to, + error: err.message, + }); + return { success: false }; + } +} + +class EmailService { + // Sends a password reset email. + // @param email - Recipient email address + // @param resetLink - Fully-qualified HTTPS reset URL (token included) + async sendPasswordResetEmail(email: string, resetLink: string): Promise { + const validatedRecipientEmail = validateRecipient(email); + const appName = resendConfig.appName; + const html = getPasswordResetTemplate(resetLink, email); + + return sendEmail( + validatedRecipientEmail, + `Reset your ${appName} password`, + html, + 'password_reset' + ); + } + + // Sends an admin password reset email + async sendAdminPasswordResetEmail( + email: string, + newPassword: string, + username: string + ): Promise { + const validatedRecipientEmail = validateRecipient(email); + const appName = resendConfig.appName; + const html = getAdminPasswordResetTemplate(newPassword, username); + + return sendEmail( + validatedRecipientEmail, + `Your ${appName} password has been reset`, + html, + 'admin_password_reset' + ); + } + + // Sends a security notification informing the user that their password was changed. + // @param email - Recipient email address + async sendPasswordChangedEmail(email: string): Promise { + const validatedRecipientEmail = validateRecipient(email); + const appName = resendConfig.appName; + const html = getPasswordChangedTemplate(); + + return sendEmail( + validatedRecipientEmail, + `Your ${appName} password was changed`, + html, + 'security_alert' + ); + } + + // Booking flow emails + // Sends a booking confirmation email to the customer. + // @param email - Customer email address + // @param details - Booking details for rendering the email body + async sendBookingConfirmation( + email: string, + details: { + venueName: string; + date: string; + startTime: string; + endTime: string; + amount: number; + paymentReference: string; + } + ): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getBookingConfirmationTemplate(details); + + return sendEmail( + validatedRecipientEmail, + `Booking Confirmed – ${details.venueName} on ${details.date}`, + html, + EmailIntent.BOOKING_CONFIRMATION + ); + } + + // Sends a refund notification to the customer when their booking fails + // due to a slot collision after the lock TTL expired. + async sendRefundNotification( + email: string, + details: { + venueName: string; + date: string; + startTime: string; + endTime: string; + amount: number; + refundReference: string; + } + ): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getRefundNotificationTemplate(details); + + return sendEmail( + validatedRecipientEmail, + `Booking Failed – Full Refund Initiated for ${details.venueName}`, + html, + EmailIntent.BOOKING_REFUND + ); + } + + // Sends a cancellation notification with refund details. + async sendBookingCancellationEmail( + email: string, + details: { + venueName: string; + date: string; + timeRange: string; + refundAmount: number; + bookingRef: string; + } + ): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getBookingCancellationTemplate(details); + + return sendEmail( + validatedRecipientEmail, + `Booking Cancelled – ${details.venueName}`, + html, + EmailIntent.BOOKING_CANCELLATION + ); + } + + // Venue status emails + async sendVenueApprovedEmail(email: string, venueName: string): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getVenueApprovedTemplate(venueName); + + return sendEmail( + validatedRecipientEmail, + `Your Venue "${venueName}" has been Approved!`, + html, + EmailIntent.VENUE_APPROVED + ); + } + + async sendVenueRejectedEmail( + email: string, + venueName: string, + reason: string, + editDeadline: Date, + submissionNumber: number + ): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getVenueRejectedTemplate(venueName, reason, editDeadline, submissionNumber); + + return sendEmail( + validatedRecipientEmail, + `Application Update: Venue "${venueName}"`, + html, + EmailIntent.VENUE_REJECTED + ); + } + + async sendVenueSuspendedEmail( + email: string, + venueName: string, + reason: string + ): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getVenueSuspendedTemplate(venueName, reason); + + return sendEmail( + validatedRecipientEmail, + `Important: Your Venue "${venueName}" has been Suspended`, + html, + EmailIntent.VENUE_SUSPENDED + ); + } + + async sendVenueUnsuspendedEmail(email: string, venueName: string): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getVenueUnsuspendedTemplate(venueName); + + return sendEmail( + validatedRecipientEmail, + `Your Venue "${venueName}" has been Reactivated`, + html, + EmailIntent.VENUE_UNSUSPENDED + ); + } + + async sendVenueDeadlineExtendedEmail( + email: string, + venueName: string, + newDeadline: Date + ): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getVenueDeadlineExtendedTemplate(venueName, newDeadline); + + return sendEmail( + validatedRecipientEmail, + `Edit Deadline Extended for "${venueName}"`, + html, + EmailIntent.VENUE_UNSUSPENDED // reuse existing intent or create new one + ); + } + + // Moderation emails + async sendUserBannedEmail( + email: string, + details: { + scope: string; + reason: string; + expiresAt: Date | null; + venueName?: string; + } + ): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getUserBannedTemplate( + details.scope, + details.reason, + details.expiresAt, + details.venueName + ); + + return sendEmail( + validatedRecipientEmail, + 'Account Restriction Applied', + html, + EmailIntent.USER_BANNED + ); + } + + async sendUserUnbannedEmail(email: string): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getUserUnbannedTemplate(); + + return sendEmail( + validatedRecipientEmail, + 'Account Restriction Lifted', + html, + EmailIntent.USER_UNBANNED + ); + } + + async sendReviewRemovedEmail( + email: string, + details: { + venueName: string; + reason: string; + } + ): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getReviewRemovedTemplate(details.venueName, details.reason); + + return sendEmail( + validatedRecipientEmail, + 'Your Review Has Been Removed', + html, + EmailIntent.REVIEW_REMOVED + ); + } + + async sendReviewRestoredEmail(email: string, venueName: string): Promise { + const validatedRecipientEmail = validateRecipient(email); + const html = getReviewRestoredTemplate(venueName); + + return sendEmail( + validatedRecipientEmail, + 'Your Review Has Been Restored', + html, + EmailIntent.REVIEW_RESTORED + ); + } +} + +export const emailService = new EmailService(); diff --git a/server/src/services/emailTemplateFactory.ts b/server/src/services/emailTemplateFactory.ts new file mode 100644 index 0000000000..39271d256c --- /dev/null +++ b/server/src/services/emailTemplateFactory.ts @@ -0,0 +1,640 @@ +import { resendConfig } from '../constants/env'; +import { VENUE_CONSTANTS } from '../constants/venue.constants'; + +function buildEmailWrapper(bodyContent: string): string { + const appName = resendConfig.appName; + const currentYear = new Date().getFullYear(); + + return ` + + + + + + ${appName} + + + + + + +
+ + + + + + + + + + + + + +
+ ${appName} +
+ ${bodyContent} +
+
+
+

+ This email was sent by ${appName}. If you have questions, reply to this email. +

+

+ If you did not request this, you can safely ignore this email. + Your account remains secure and no action is required. +

+

+ © ${String(currentYear)} ${appName}. All rights reserved. +

+
+
+ +`; +} + +export function getPasswordResetTemplate(resetLink: string, email: string): string { + const appName = resendConfig.appName; + const isDev = process.env.NODE_ENV === 'development'; + + const bodyContent = ` +

Reset your password

+

+ We received a request to reset the password for your ${appName} account. + Click the button below to choose a new password. +

+ + + + +
+ + Reset Password + +
+

+ This link expires in 15 minutes. After that you will need to request a new one. +

+ + + + +
+

+ Security notice: If you did not request a password reset, + please ignore this email. Your password will not be changed and your account + remains secure. +

+
+

+ If the button above does not work, copy and paste the following URL into your browser. + Do not share this link with anyone. +

+

+ ${resetLink} +

+ ${isDev ? `

Development mode - Email sent to ${email}

` : ''} + `; + + return buildEmailWrapper(bodyContent); +} + +// Admin Password Reset Template +export function getAdminPasswordResetTemplate(newPassword: string, username: string): string { + const appName = resendConfig.appName; + const bodyContent = ` +

Your Password Has Been Reset

+

+ Hi ${username}, an administrator has reset your password for your ${appName} account. +

+
+

Your new temporary password is:

+

${newPassword}

+
+

+ Please log in with this new password and change it immediately from your profile settings. +

+ `; + + return buildEmailWrapper(bodyContent); +} + +export function getPasswordChangedTemplate(): string { + const appName = resendConfig.appName; + + const bodyContent = ` +

Your password was changed

+

+ The password for your ${appName} account was successfully changed. +

+ + + + +
+

+ Was this you? No action is required. Your account is secure.
+ Was this NOT you? Contact our support team immediately so we can + help secure your account. +

+
+

+ For your security, all active sessions have been signed out. You will need to log in + again with your new password. +

+ `; + + return buildEmailWrapper(bodyContent); +} + +export function getBookingConfirmationTemplate(details: { + venueName: string; + date: string; + startTime: string; + endTime: string; + amount: number; + paymentReference: string; +}): string { + const bodyContent = ` +

Booking Confirmed! 🎉

+

+ Your booking at ${details.venueName} is confirmed. + Here's a summary of your reservation. +

+ + + + +
+

Venue

+

${details.venueName}

+

Date

+

${details.date}

+

Time

+

${details.startTime} – ${details.endTime}

+

Amount Paid

+

₹${details.amount.toLocaleString('en-IN')}

+
+

+ Payment Reference: ${details.paymentReference} +

+

+ We look forward to hosting your event. If you have any questions, please reply to this email. +

+ `; + + return buildEmailWrapper(bodyContent); +} + +export function getRefundNotificationTemplate(details: { + venueName: string; + date: string; + startTime: string; + endTime: string; + amount: number; + refundReference: string; +}): string { + const appName = resendConfig.appName; + + const bodyContent = ` +

Booking Unsuccessful – Refund Initiated

+

+ We're sorry — by the time your payment was processed, the selected slot at + ${details.venueName} had been booked by another customer. + We have initiated a full refund. +

+ + + + +
+

Venue

+

${details.venueName}

+

Requested Date

+

${details.date}

+

Time Slot

+

${details.startTime} – ${details.endTime}

+

Refund Amount

+

₹${details.amount.toLocaleString('en-IN')}

+
+ + + + +
+

+ Refund Timeline: Refunds typically reflect in your account within + 5–7 business days depending on your bank. +

+
+

+ Refund Reference: ${details.refundReference} +

+

+ We apologise for the inconvenience. Please try booking another available slot on ${appName}. +

+ `; + + return buildEmailWrapper(bodyContent); +} + +export function getBookingCancellationTemplate(details: { + venueName: string; + date: string; + timeRange: string; + refundAmount: number; + bookingRef: string; +}): string { + const appName = resendConfig.appName; + + const bodyContent = ` +

Booking Cancelled

+

+ Your booking at ${details.venueName} has been successfully cancelled. +

+ + + + +
+

Venue

+

${details.venueName}

+

Date

+

${details.date}

+

Time Slot

+

${details.timeRange}

+

Refund Amount

+

₹${details.refundAmount.toLocaleString('en-IN')}

+
+ + + + +
+

+ Important notice: If this cancellation was a mistake or was not done by you, + please contact our support team immediately. +

+
+

+ Booking Reference: ${details.bookingRef} +

+

+ We hope to host you another time on ${appName}. +

+ `; + return buildEmailWrapper(bodyContent); +} + +export function getVenueApprovedTemplate(venueName: string): string { + const bodyContent = ` +

Venue Approved! 🎉

+

+ Great news! Your venue ${venueName} has been approved by our admin team and is now live on the platform. +

+ + + + +
+

+ Users can now view and book your venue. Make sure your availability and pricing are up to date! +

+
+ `; + return buildEmailWrapper(bodyContent); +} + +export function getVenueRejectedTemplate( + venueName: string, + reason: string, + editDeadline: Date, + submissionNumber: number +): string { + const deadlineStr = editDeadline.toLocaleDateString('en-IN', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + const daysLeft = Math.ceil((editDeadline.getTime() - Date.now()) / (24 * 60 * 60 * 1000)); + + const maxAttempts = VENUE_CONSTANTS.MAX_SUBMISSION_ATTEMPTS.toString(); + + const attemptNotice = + submissionNumber >= 7 + ? `

+ This is submission attempt #${String(submissionNumber)} of ${maxAttempts}. +

` + : ''; + + const bodyContent = ` +

Venue Application Update

+

+ We reviewed your application for ${venueName}, but unfortunately, we cannot approve it at this time. +

+ + + + +
+

Reason for Rejection:

+

${reason}

+
+ ${attemptNotice} + + + + +
+

⏰ Action Required: Edit & Resubmit

+

+ You have ${daysLeft.toString()} days (until ${deadlineStr}) + to edit your venue and resubmit for review. +

+

+ After this deadline, your venue will be auto-suspended and no longer editable. +

+
+

+ You can update your venue details in the owner dashboard and submit it again for review once the issues have been addressed. +

+ `; + return buildEmailWrapper(bodyContent); +} + +export function getVenueSuspendedTemplate(venueName: string, reason: string): string { + const bodyContent = ` +

Venue Suspended

+

+ Your venue ${venueName} has been temporarily suspended by our admin team and is currently not visible to users. +

+ + + + +
+

Reason for Suspension:

+

${reason}

+
+

+ Please contact our support team to resolve this issue and have your venue unsuspended. +

+ `; + return buildEmailWrapper(bodyContent); +} + +export function getVenueUnsuspendedTemplate(venueName: string): string { + const bodyContent = ` +

Your Venue Has Been Reactivated ✅

+

+ Great news! Your venue ${venueName} has been reactivated and is now visible to customers again. +

+ + + + +
+

Venue

+

${venueName}

+
+

+ You can now manage your venue and accept bookings as usual. If you have any questions, please reply to this email. +

+ `; + + return buildEmailWrapper(bodyContent); +} + +export function getVenueDeadlineExtendedTemplate(venueName: string, newDeadline: Date): string { + const deadlineStr = newDeadline.toLocaleDateString('en-IN', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + const daysLeft = Math.ceil((newDeadline.getTime() - Date.now()) / (24 * 60 * 60 * 1000)); + + const bodyContent = ` +

Edit Deadline Extended ✅

+

+ Good news! The deadline to edit and resubmit your venue ${venueName} has been extended. +

+ + + + +
+

New Deadline

+

${deadlineStr}

+

+ You now have ${daysLeft.toString()} days to make changes and resubmit. +

+
+

+ Please update your venue details and submit for review before the new deadline. +

+ `; + return buildEmailWrapper(bodyContent); +} + +interface BanScopeDisplay { + label: string; + description: string; +} + +function getBanScopeDisplay(scope: string, venueName?: string): BanScopeDisplay { + switch (scope) { + case 'full': + return { + label: 'Full Platform Ban', + description: + 'You cannot access any features of the platform. Your account has been deactivated.', + }; + case 'commenting': + return { + label: 'Commenting Ban', + description: venueName + ? `You cannot post reviews or comments on ${venueName}.` + : 'You cannot post reviews or comments on any venue.', + }; + case 'owner_dashboard': + return { + label: 'Owner Dashboard Ban', + description: venueName + ? `You cannot access the owner dashboard for ${venueName}.` + : 'You cannot access the owner dashboard for any of your venues.', + }; + case 'venue_creation': + return { + label: 'Venue Creation Ban', + description: 'You cannot create new venues on the platform.', + }; + default: + return { + label: 'Account Restriction', + description: 'Your account has been restricted.', + }; + } +} + +export function getUserBannedTemplate( + scope: string, + reason: string, + expiresAt: Date | null, + venueName?: string +): string { + const scopeDisplay = getBanScopeDisplay(scope, venueName); + const appName = resendConfig.appName; + const isDev = process.env.NODE_ENV === 'development'; + + const expiryHtml = expiresAt + ? `

This ban expires on: ${expiresAt.toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' })} at ${expiresAt.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}

` + : '

This ban is permanent unless lifted by an administrator.

'; + + const bodyContent = ` +

Account Restriction Applied

+

+ Your ${appName} account has been restricted. Please review the details below. +

+ + + + +
+

Restriction Type

+

${scopeDisplay.label}

+

${scopeDisplay.description}

+

Reason

+

${reason}

+
+ ${expiryHtml} + + + + +
+

+ What this means: You will not be able to perform actions related to this restriction. + If you believe this was a mistake, please contact our support team by replying to this email. +

+
+ ${isDev ? `

Development mode: This email was redirected to the development address. The original recipient email is shown in the application logs.

` : ''} + `; + + return buildEmailWrapper(bodyContent); +} + +export function getUserUnbannedTemplate(): string { + const appName = resendConfig.appName; + const isDev = process.env.NODE_ENV === 'development'; + + const bodyContent = ` +

Account Restriction Lifted ✅

+

+ Good news! The restriction on your ${appName} account has been lifted. +

+ + + + +
+

Status

+

All restrictions removed

+
+

+ You can now use all platform features as normal. If you have any questions, please reply to this email. +

+ ${isDev ? `

Development mode: This email was redirected to the development address. The original recipient email is shown in the application logs.

` : ''} + `; + + return buildEmailWrapper(bodyContent); +} + +export function getReviewRemovedTemplate(venueName: string, reason: string): string { + const appName = resendConfig.appName; + const isDev = process.env.NODE_ENV === 'development'; + + const bodyContent = ` +

Your Review Has Been Removed

+

+ An administrator has removed your review for ${venueName} on ${appName}. +

+ + + + +
+

Venue

+

${venueName}

+

Reason for Removal

+

${reason}

+
+ + + + +
+

+ Note: Reviews are removed when they violate our community guidelines. + If you believe this was a mistake, please contact our support team by replying to this email. +

+
+ ${isDev ? `

Development mode: This email was redirected to the development address. The original recipient email is shown in the application logs.

` : ''} + `; + + return buildEmailWrapper(bodyContent); +} + +export function getReviewRestoredTemplate(venueName: string): string { + const appName = resendConfig.appName; + const isDev = process.env.NODE_ENV === 'development'; + + const bodyContent = ` +

Your Review Has Been Restored ✅

+

+ An administrator has restored your review for ${venueName} on ${appName}. +

+ + + + +
+

Venue

+

${venueName}

+
+

+ Your review is now visible to other users. If you have any questions, please reply to this email. +

+ ${isDev ? `

Development mode: This email was redirected to the development address. The original recipient email is shown in the application logs.

` : ''} + `; + + return buildEmailWrapper(bodyContent); +} diff --git a/server/src/services/razorpay.service.ts b/server/src/services/razorpay.service.ts new file mode 100644 index 0000000000..0ea3e81c73 --- /dev/null +++ b/server/src/services/razorpay.service.ts @@ -0,0 +1,237 @@ +/** + * razorpay.service.ts + * + * Thin, strongly-typed wrapper around the Razorpay Node SDK. + * Exposes three domain functions: + * - createOrder — Standard Orders API (used in checkout init) + * - verifyWebhookSignature — HMAC-SHA256 verification for incoming webhooks + * - issueRefund — Full refund for a captured payment + * + * The Razorpay client is a lazy singleton: instantiated on first call after + * env-vars are validated, never re-created on subsequent calls. + */ + +import Razorpay from 'razorpay'; +import crypto from 'crypto'; +import { razorpayConfig } from '../constants/env'; +import { logError, logInfo } from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Client singleton +// --------------------------------------------------------------------------- + +let _razorpayClient: Razorpay | null = null; + +function getRazorpayClient(): Razorpay { + if (_razorpayClient) return _razorpayClient; + + const { keyId, keySecret } = razorpayConfig; + + if (!keyId || !keySecret) { + throw new Error( + 'Razorpay credentials are not configured. ' + + 'Set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET in your environment.' + ); + } + + _razorpayClient = new Razorpay({ key_id: keyId, key_secret: keySecret }); + return _razorpayClient; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CreateOrderParams { + /** Amount in smallest currency unit (paise for INR) */ + amountPaise: number; + currency?: string; + /** Metadata forwarded to webhook notes */ + notes: { + lockId: string; + venueId: string; + userId: string; + }; +} + +export interface CreateOrderResult { + orderId: string; + amount: number; + currency: string; +} + +export interface IssueRefundResult { + refundId: string; +} + +// --------------------------------------------------------------------------- +// createOrder +// --------------------------------------------------------------------------- + +/** + * Creates a Razorpay Standard Order. + * Passes lockId, venueId, userId in the `notes` object so they are + * available in the webhook payload without any additional DB lookup. + */ +export async function createOrder(params: CreateOrderParams): Promise { + const client = getRazorpayClient(); + const currency = params.currency ?? 'INR'; + + try { + const order = await client.orders.create({ + amount: params.amountPaise, + currency, + notes: { + lockId: params.notes.lockId, + venueId: params.notes.venueId, + userId: params.notes.userId, + }, + }); + + logInfo('Razorpay order created', { + orderId: order.id, + amount: order.amount, + currency: order.currency, + }); + + return { + orderId: order.id, + amount: typeof order.amount === 'string' ? parseInt(order.amount, 10) : order.amount, + currency: order.currency, + }; + } catch (err) { + const error = err as Error; + logError('Razorpay order creation failed', { + module: 'razorpay.service.ts/createOrder', + error: error.message, + }); + throw error; + } +} + +// --------------------------------------------------------------------------- +// verifyWebhookSignature +// --------------------------------------------------------------------------- + +/** + * Validates the Razorpay webhook HMAC-SHA256 signature. + * + * IMPORTANT: `rawBody` must be the raw Buffer from `express.raw()`. + * If the body has been parsed by express.json() the bytes will differ + * from what Razorpay signed and every request will be rejected. + * + * Uses `timingSafeEqual` to prevent timing-oracle attacks. + * + * @returns true if the signature is valid, false otherwise. + */ +export function verifyWebhookSignature(rawBody: Buffer, signature: string): boolean { + const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET ?? razorpayConfig.webhookSecret; + + if (!webhookSecret) { + logError('RAZORPAY_WEBHOOK_SECRET is not configured', { + module: 'razorpay.service.ts/verifyWebhookSignature', + }); + return false; + } + + try { + const expectedSignature = crypto + .createHmac('sha256', webhookSecret) + .update(rawBody) + .digest('hex'); + + const expectedBuffer = Buffer.from(expectedSignature, 'utf8'); + const receivedBuffer = Buffer.from(signature, 'utf8'); + + // Buffers must be the same length for timingSafeEqual + if (expectedBuffer.length !== receivedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(expectedBuffer, receivedBuffer); + } catch (err) { + logError('Webhook signature verification threw an error', { + module: 'razorpay.service.ts/verifyWebhookSignature', + error: (err as Error).message, + }); + return false; + } +} + +export interface VerifyPaymentParams { + orderId: string; + paymentId: string; + signature: string; +} + +export function verifyPaymentSignature(params: VerifyPaymentParams): boolean { + const { keySecret } = razorpayConfig; + + if (!keySecret) { + logError('RAZORPAY_KEY_SECRET is not configured', { + module: 'razorpay.service.ts/verifyPaymentSignature', + }); + return false; + } + + try { + const payload = `${params.orderId}|${params.paymentId}`; + const expectedSignature = crypto.createHmac('sha256', keySecret).update(payload).digest('hex'); + + const expectedBuffer = Buffer.from(expectedSignature, 'utf8'); + const receivedBuffer = Buffer.from(params.signature, 'utf8'); + + if (expectedBuffer.length !== receivedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(expectedBuffer, receivedBuffer); + } catch (err) { + logError('Payment signature verification threw an error', { + module: 'razorpay.service.ts/verifyPaymentSignature', + error: (err as Error).message, + }); + return false; + } +} + +// --------------------------------------------------------------------------- +// issueRefund +// --------------------------------------------------------------------------- + +/** + * Issues a full refund for a captured Razorpay payment. + * + * @param paymentId - The Razorpay payment_id from the webhook payload + * @param amountPaise - The exact amount to refund (paise); must equal the captured amount for full refund + */ +export async function issueRefund( + paymentId: string, + amountPaise: number +): Promise { + const client = getRazorpayClient(); + + try { + const refund = await client.payments.refund(paymentId, { + amount: amountPaise, + speed: 'normal', + notes: { reason: 'TTL_EXPIRED_COLLISION — slot unavailable at payment time' }, + }); + + logInfo('Razorpay refund issued', { + refundId: refund.id, + paymentId, + amount: refund.amount, + }); + + return { refundId: refund.id }; + } catch (err) { + const error = err as Error; + logError('Razorpay refund failed', { + module: 'razorpay.service.ts/issueRefund', + paymentId, + error: error.message, + }); + throw error; + } +} diff --git a/server/src/services/roles.service.ts b/server/src/services/roles.service.ts new file mode 100644 index 0000000000..9d8997fa82 --- /dev/null +++ b/server/src/services/roles.service.ts @@ -0,0 +1,253 @@ +import mongoose from 'mongoose'; +import { UserRoleModel } from '../models/user-role.model'; +import { RoleModel } from '../models/role.model'; +import { PermissionModel } from '../models/permission.model'; +import { RolePermissionModel } from '../models/role-permission.model'; +import { logError, logInfo } from '../utils/logger'; + +export interface UserRoleInfo { + roleId: string; + roleName: string; +} + +export async function getUserRole(userId: string): Promise { + try { + const result = await UserRoleModel.aggregate([ + { + $match: { + userId: new mongoose.Types.ObjectId(userId), + active: true, + deleted: false, + }, + }, + { + $lookup: { + from: 'Roles', + localField: 'roleId', + foreignField: '_id', + as: 'role', + pipeline: [{ $match: { active: true, deleted: false } }, { $project: { name: 1 } }], + }, + }, + { $unwind: '$role' }, + { + $addFields: { + priority: { + $switch: { + branches: [ + { case: { $eq: ['$role.name', 'superAdmin'] }, then: 4 }, + { case: { $eq: ['$role.name', 'admin'] }, then: 3 }, + { case: { $eq: ['$role.name', 'owner'] }, then: 2 }, + { case: { $eq: ['$role.name', 'user'] }, then: 1 }, + ], + default: 0, + }, + }, + }, + }, + { $sort: { priority: -1 } }, + { $limit: 1 }, + { + $project: { + _id: 0, + roleId: { $toString: '$roleId' }, + roleName: '$role.name', + }, + }, + ]); + + return result[0] ?? null; + } catch (e) { + const error = e as Error; + logError('getUserRole failed', { + module: 'roles.service.ts/getUserRole', + error: error.message, + userId, + }); + return null; + } +} + +export async function fetchRolePermissions(roleId: string): Promise { + try { + const result = await RoleModel.aggregate<{ permissions: string[] }>([ + // 1. Start from the user's own role + { + $match: { + _id: new mongoose.Types.ObjectId(roleId), + active: true, + deleted: false, + }, + }, + // 2. Walk the parentRole chain to collect all ancestors + { + $graphLookup: { + from: 'Roles', + startWith: '$parentRole', + connectFromField: 'parentRole', + connectToField: '_id', + as: 'ancestors', + restrictSearchWithMatch: { active: true, deleted: false }, + }, + }, + // 3. Combine own role ID + all ancestor IDs into one array + { + $project: { + _id: 0, + roleIds: { + $concatArrays: [['$_id'], '$ancestors._id'], + }, + }, + }, + // 4. Fetch all RolePermission grants for the full role set + { + $lookup: { + from: 'RolePermissions', + localField: 'roleIds', + foreignField: 'roleId', + as: 'rolePermissions', + pipeline: [ + { $match: { active: true, deleted: false } }, + { $project: { permissionId: 1, _id: 0 } }, + ], + }, + }, + // 5. Resolve permission documents + { + $lookup: { + from: 'Permissions', + localField: 'rolePermissions.permissionId', + foreignField: '_id', + as: 'permissions', + pipeline: [ + { $match: { active: true, deleted: false } }, + { $project: { action: 1, entity: 1, _id: 0 } }, + ], + }, + }, + { $unwind: '$permissions' }, + // 6. Format as 'action:entity' and deduplicate + { + $group: { + _id: null, + permissions: { + $addToSet: { + $concat: ['$permissions.action', ':', '$permissions.entity'], + }, + }, + }, + }, + ]); + + return result.length > 0 ? result[0].permissions : []; + } catch (e) { + const error = e as Error; + logError('fetchRolePermissions failed', { + module: 'roles.service.ts/fetchRolePermissions', + error: error.message, + roleId, + }); + return []; + } +} + +export async function verifyRbacSeed(): Promise { + try { + const requiredRoles = ['user', 'owner', 'admin', 'superAdmin']; + + // Check all required roles exist + const roleCount = await RoleModel.countDocuments({ + active: true, + deleted: false, + }); + + if (roleCount === 0) { + throw new Error( + 'No active roles found in database. Run "pnpm script seed:rbac" to initialize RBAC data.' + ); + } + + for (const roleName of requiredRoles) { + const exists = await RoleModel.exists({ + name: roleName, + active: true, + deleted: false, + }); + + if (!exists) { + throw new Error( + `Required role "${roleName}" not found in database. Run "pnpm script seed:rbac" to initialize RBAC data.` + ); + } + } + + // Check permissions exist + const permissionCount = await PermissionModel.countDocuments({ + active: true, + deleted: false, + }); + + if (permissionCount === 0) { + throw new Error( + 'No active permissions found in database. Run "pnpm script seed:rbac" to initialize RBAC data.' + ); + } + + // Check role-permission links exist + const rolePermissionCount = await RolePermissionModel.countDocuments({ + active: true, + deleted: false, + }); + + if (rolePermissionCount === 0) { + throw new Error( + 'No active role-permission links found in database. Run "pnpm script seed:rbac" to initialize RBAC data.' + ); + } + + // Check admin and superAdmin have at least one permission + const adminRole = await RoleModel.findOne({ name: 'admin', active: true, deleted: false }); + const superAdminRole = await RoleModel.findOne({ + name: 'superAdmin', + active: true, + deleted: false, + }); + + if (adminRole) { + const adminPerms = await RolePermissionModel.countDocuments({ + roleId: adminRole._id, + active: true, + deleted: false, + }); + + if (adminPerms === 0) { + throw new Error( + 'Admin role has no permissions. Run "pnpm script seed:rbac" to initialize RBAC data.' + ); + } + } + + if (superAdminRole) { + const superAdminPerms = await RolePermissionModel.countDocuments({ + roleId: superAdminRole._id, + active: true, + deleted: false, + }); + + if (superAdminPerms === 0) { + throw new Error( + 'SuperAdmin role has no permissions. Run "pnpm script seed:rbac" to initialize RBAC data.' + ); + } + } + + logInfo('RBAC verified'); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + logError('RBAC seed verification failed', { + module: 'roles.service.ts/verifyRbacSeed', + error: error.message, + }); + process.exit(1); + } +} diff --git a/server/src/types/express.ts b/server/src/types/express.ts new file mode 100644 index 0000000000..0989d5661a --- /dev/null +++ b/server/src/types/express.ts @@ -0,0 +1,70 @@ +import type { IRefreshToken } from '../modules/auth/models/refresh-token.model'; +import type { IPermission } from '../constants/permissions'; +import type { PaginationParams } from './pagination.types'; + +export type StoredToken = IRefreshToken; + +export interface VerifiedRole { + id?: string; + name?: string; + isSuperAdmin?: boolean; + permissions?: Set; +} + +export interface AuthenticatedUser { + userId: string; + username: string; + email: string; + role: VerifiedRole; +} + +export type Permission = IPermission; + +export interface DecodedToken { + id: string; + jti: string; +} + +export interface TokenPayload { + id: string; + username: string; + email: string; + iat: number; + jti?: string; +} + +export interface RefreshTokenPayload { + id: string; + iat?: number; + jti: string; +} + +export interface ApiResponse { + success: boolean; + message: string; + data?: T; + error?: string; + code?: string; +} + +export interface ValidatedRequest { + body?: unknown; + params?: unknown; + query?: unknown; +} + +export interface RequestToken { + decoded: DecodedToken; + stored: IRefreshToken; +} + +declare global { + namespace Express { + interface Request { + user?: AuthenticatedUser; + token?: RequestToken; + validated?: ValidatedRequest; + pagination?: PaginationParams; + } + } +} diff --git a/server/src/types/pagination.types.ts b/server/src/types/pagination.types.ts new file mode 100644 index 0000000000..767da87d04 --- /dev/null +++ b/server/src/types/pagination.types.ts @@ -0,0 +1,45 @@ +/** + * Raw query-string shape accepted by the pagination middleware. + * All fields are optional strings or numbers because Express gives everything as strings. + */ +export interface PaginationQuery { + page?: string | number; + limit?: string | number; + skip?: string | number; + sort?: string; +} + +// Normalised, validated pagination parameters attached to `req.pagination` +// after the pagination middleware runs. +export interface PaginationParams { + // 1-based current page number. + page: number; + // Number of documents per page. + limit: number; + // Number of documents to skip — derived from page/limit or an explicit skip query param. + skip: number; + // Mongo-style sort string, e.g. `-createdAt` or `name,-updatedAt`. + sort: string; +} + +// Pagination metadata included in every paginated API response. +export interface PaginationMeta { + total: number; + page: number; + limit: number; + skip: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} + +export type PaginatedResponse = Record & { + pagination: PaginationMeta; +}; + +export interface PaginationMiddlewareOptions { + defaultLimit?: number; + maxLimit?: number; + minLimit?: number; + defaultSort?: string; +} diff --git a/server/src/utils/cacheUtils.ts b/server/src/utils/cacheUtils.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/src/utils/cloudinarySign.ts b/server/src/utils/cloudinarySign.ts new file mode 100644 index 0000000000..0d3bb048f9 --- /dev/null +++ b/server/src/utils/cloudinarySign.ts @@ -0,0 +1,43 @@ +import { v2 as cloudinary } from 'cloudinary'; + +// The SDK only self-configures from a combined CLOUDINARY_URL env var (it uses the split CLOUDINARY_CLOUD_NAME/API_KEY/ API_SECRET vars instead). +// `cloudinary.utils.api_sign_request` takes the secret as an explicit argument so it worked without this, but any real SDK call — `cloudinary.uploader.destroy`, +// `cloudinary.api.resource`, etc. — needs the SDK actually configured, so do it once here as a side effect of importing this module +// (every module that touches Cloudinary imports it). +if ( + process.env.CLOUDINARY_CLOUD_NAME && + process.env.CLOUDINARY_API_KEY && + process.env.CLOUDINARY_API_SECRET +) { + cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, + }); +} + +export interface CloudinarySignature { + signature: string; + timestamp: number; + cloudName: string; + apiKey: string; + uploadPreset: string; + folder: string; +} + +export function signUploadParams(folder: string): CloudinarySignature | null { + const apiSecret = process.env.CLOUDINARY_API_SECRET; + const cloudName = process.env.CLOUDINARY_CLOUD_NAME; + const apiKey = process.env.CLOUDINARY_API_KEY; + const uploadPreset = process.env.CLOUDINARY_UPLOAD_PRESET; + + if (!apiSecret || !cloudName || !apiKey || !uploadPreset) { + return null; + } + + const timestamp = Math.round(Date.now() / 1000); + const paramsToSign = { folder, timestamp, upload_preset: uploadPreset }; + const signature = cloudinary.utils.api_sign_request(paramsToSign, apiSecret); + + return { signature, timestamp, cloudName, apiKey, uploadPreset, folder }; +} diff --git a/server/src/utils/dbUtils.ts b/server/src/utils/dbUtils.ts new file mode 100644 index 0000000000..bd21827dc9 --- /dev/null +++ b/server/src/utils/dbUtils.ts @@ -0,0 +1,27 @@ +import mongoose from 'mongoose'; + +export async function runInTransaction( + work: (session: mongoose.ClientSession) => Promise +): Promise { + const session = await mongoose.startSession(); + try { + return await session.withTransaction(() => work(session), { + maxCommitTimeMS: 10000, + }); + } catch (error) { + if ( + error instanceof Error && + (error.message.includes('Unable to acquire IX lock') || + error.message.includes('TransientTransactionError') || + error.message.includes('WriteConflict')) + ) { + await new Promise((resolve) => setTimeout(resolve, 100)); + return await session.withTransaction(() => work(session), { + maxCommitTimeMS: 10000, + }); + } + throw error; + } finally { + await session.endSession(); + } +} diff --git a/server/src/utils/errors.ts b/server/src/utils/errors.ts new file mode 100644 index 0000000000..53e5b73fd5 --- /dev/null +++ b/server/src/utils/errors.ts @@ -0,0 +1,123 @@ +import { z } from 'zod'; +import type { Response } from 'express'; +import { logError } from './logger'; +import { ResponseUtil } from './responseUtils'; + +/** + * Typed application error hierarchy. + * + * All domain modules (property, venue, booking, …) throw these instead of plain + * Error objects so controllers can map them to HTTP status codes without + * inspecting error messages. + * + * Usage in a controller: + * catch (e) { + * if (e instanceof NotFoundError) return ResponseUtil.notFound(res, e.message); + * if (e instanceof ForbiddenError) return ResponseUtil.forbidden(res, e.message); + * … + * } + */ + +export class AppError extends Error { + public readonly statusCode: number; + public readonly code: string; + + constructor(message: string, statusCode: number, code: string) { + super(message); + this.name = this.constructor.name; + this.statusCode = statusCode; + this.code = code; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// 404 — resource not found +export class NotFoundError extends AppError { + constructor(message = 'Resource not found') { + super(message, 404, 'NOT_FOUND'); + } +} + +// 403 — authenticated but not authorised (ownership failure) +export class ForbiddenError extends AppError { + constructor(message = 'Forbidden') { + super(message, 403, 'FORBIDDEN'); + } +} + +// 409 — uniqueness violation +export class ConflictError extends AppError { + constructor(message = 'Conflict') { + super(message, 409, 'CONFLICT'); + } +} + +// 422 — input passed schema validation but violates a business rule +export class ValidationError extends AppError { + constructor(message: string) { + super(message, 422, 'VALIDATION_ERROR'); + } +} + +/** + * 422 — illegal state-machine transition. + * + * Thrown by property.workflow.ts when a requested status change is not allowed + * from the property's current status. + */ +export class WorkflowError extends AppError { + public readonly currentStatus: string; + public readonly attemptedTransition: string; + + constructor(currentStatus: string, attemptedTransition: string) { + super( + `Cannot perform '${attemptedTransition}': current status is '${currentStatus}'`, + 422, + 'WORKFLOW_ERROR' + ); + this.currentStatus = currentStatus; + this.attemptedTransition = attemptedTransition; + } +} + +export function handleError(res: Response, error: unknown, context: string): void { + if (error instanceof z.ZodError) { + ResponseUtil.badRequest(res, 'Invalid request parameters'); + return; + } + if (error instanceof AppError) { + switch (error.statusCode) { + case 400: + ResponseUtil.badRequest(res, error.message); + return; + case 401: + ResponseUtil.unauthorized(res, error.message); + return; + case 403: + ResponseUtil.forbidden(res, error.message); + return; + case 404: + ResponseUtil.notFound(res, error.message); + return; + case 409: + ResponseUtil.conflict(res, error.message); + return; + case 422: + ResponseUtil.validationError(res, error.message); + return; + case 429: + ResponseUtil.rateLimitExceeded(res, error.message); + return; + default: + ResponseUtil.error(res, error.message, undefined, error.statusCode); + return; + } + } + const err = error as Error; + logError(`${context}: unexpected error`, { + module: 'errorUtils.ts/handleError', + error: err.message, + stack: err.stack, + }); + ResponseUtil.internalServerError(res, 'Server error'); +} diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts new file mode 100644 index 0000000000..ce0630f085 --- /dev/null +++ b/server/src/utils/logger.ts @@ -0,0 +1,12 @@ +export { + logError, + logWarn, + logInfo, + logHttp, + logDebug, + logVerbose, + logSilly, + default as logger, +} from './winston/logger'; + +export { requestLogger } from './winston/requestLogger'; diff --git a/server/src/utils/mutex.ts b/server/src/utils/mutex.ts new file mode 100644 index 0000000000..7dbab2c365 --- /dev/null +++ b/server/src/utils/mutex.ts @@ -0,0 +1,34 @@ +import { Types } from 'mongoose'; +import { SlotMutexModel } from '../modules/booking/models/slotMutex.model'; + +const ACQUIRE_RETRY_ATTEMPTS = 5; +const ACQUIRE_RETRY_DELAY_MS = 200; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Attempts to acquire an exclusive mutex for a given venue+date by +// inserting a uniquely-indexed document. Retries briefly if another +// request currently holds it. Returns false if the mutex could not be +// acquired within the retry budget (caller should surface a +// "try again" error rather than proceed unsynchronized). +export async function acquireSlotMutex(venueId: string, date: string): Promise { + const vId = new Types.ObjectId(venueId); + for (let attempt = 0; attempt < ACQUIRE_RETRY_ATTEMPTS; attempt++) { + try { + await SlotMutexModel.create({ venueId: vId, date, lockedAt: new Date() }); + return true; + } catch (err) { + const error = err as { code?: number }; + if (error.code !== 11000) throw err; + await sleep(ACQUIRE_RETRY_DELAY_MS); + } + } + return false; +} + +export async function releaseSlotMutex(venueId: string, date: string): Promise { + const vId = new Types.ObjectId(venueId); + await SlotMutexModel.deleteOne({ venueId: vId, date }); +} diff --git a/server/src/utils/paginationUtils.ts b/server/src/utils/paginationUtils.ts new file mode 100644 index 0000000000..e0d65a8e7c --- /dev/null +++ b/server/src/utils/paginationUtils.ts @@ -0,0 +1,133 @@ +import type { PaginationMeta, PaginationParams, PaginationQuery } from '../types/pagination.types'; +import type { PaginationMiddlewareOptions } from '../types/pagination.types'; + +// Defaults + +export const PAGINATION_DEFAULTS = { + PAGE: 1, + LIMIT: 20, + MAX_LIMIT: 200, + MIN_LIMIT: 1, + SORT: '-createdAt', +} as const; + +// parsePaginationParams +/** + * Parses and validates raw query-string pagination fields into a normalised + * {@link PaginationParams} object. + * + * Rules: + * - `page` must be a positive integer; falls back to 1. + * - `limit` is clamped between `minLimit` and `maxLimit`. + * - `skip` can be supplied directly (useful for cursor-style clients); when + * absent it is derived from `(page - 1) * limit`. + * - `sort` defaults to `-createdAt` when not provided. + */ +export function parsePaginationParams( + query: PaginationQuery, + options?: PaginationMiddlewareOptions +): PaginationParams { + const defaultLimit = options?.defaultLimit ?? PAGINATION_DEFAULTS.LIMIT; + const maxLimit = options?.maxLimit ?? PAGINATION_DEFAULTS.MAX_LIMIT; + const minLimit = options?.minLimit ?? PAGINATION_DEFAULTS.MIN_LIMIT; + const defaultSort = options?.defaultSort ?? PAGINATION_DEFAULTS.SORT; + + // --- page --- + let page: number = PAGINATION_DEFAULTS.PAGE; + if (query.page !== undefined) { + const parsed = typeof query.page === 'string' ? parseInt(query.page, 10) : query.page; + if (!isNaN(parsed) && parsed > 0) page = parsed; + } + + // --- limit --- + let limit = defaultLimit; + if (query.limit !== undefined) { + const parsed = typeof query.limit === 'string' ? parseInt(query.limit, 10) : query.limit; + if (!isNaN(parsed) && parsed >= minLimit) limit = Math.min(parsed, maxLimit); + } + + // --- skip --- + let skip: number; + if (query.skip !== undefined) { + const parsed = typeof query.skip === 'string' ? parseInt(query.skip, 10) : query.skip; + skip = !isNaN(parsed) && parsed >= 0 ? parsed : (page - 1) * limit; + } else { + skip = (page - 1) * limit; + } + + // --- sort --- + const sort = + typeof query.sort === 'string' && query.sort.trim() ? query.sort.trim() : defaultSort; + + return { page, limit, skip, sort }; +} + +// buildPaginationMeta + +/** + * Builds the {@link PaginationMeta} object to include in paginated API + * responses. + * + * @param total - Total document count returned by `Model.countDocuments()`. + * @param params - The same {@link PaginationParams} used for the query. + */ +export function buildPaginationMeta(total: number, params: PaginationParams): PaginationMeta { + const { limit, skip } = params; + const totalPages = limit > 0 ? Math.ceil(total / limit) : 0; + const currentPage = limit > 0 ? Math.floor(skip / limit) + 1 : 1; + + return { + total, + page: currentPage, + limit, + skip, + totalPages, + hasNext: currentPage < totalPages, + hasPrev: currentPage > 1, + }; +} + +// parseSortString +/** + * Converts a sort string into a Mongoose-compatible sort object. + * + * Format: comma-separated field names, prefixed with `-` for descending. + * + * Examples: + * ``` + * "-createdAt" → { createdAt: -1, _id: -1 } + * "name" → { name: 1, _id: 1 } + * "-createdAt,name" → { createdAt: -1, name: 1, _id: -1 } + * ``` + * + * `_id` is always appended as a tiebreaker (matching the direction of the + * first sort field) to guarantee stable pagination across pages. + */ +export function parseSortString(sortString: string): Record { + const sortObj: Record = {}; + + if (!sortString.trim()) return sortObj; + + let primaryDirection: 1 | -1 = -1; + + for (const rawField of sortString.split(',')) { + const field = rawField.trim(); + if (!field) continue; + + if (field.startsWith('-')) { + const name = field.slice(1); + sortObj[name] = -1; + if (Object.keys(sortObj).length === 1) primaryDirection = -1; + } else { + sortObj[field] = 1; + if (Object.keys(sortObj).length === 1) primaryDirection = 1; + } + } + + // Stable tiebreaker — only add if not already in the sort object + if (!('_id' in sortObj)) { + sortObj._id = primaryDirection; + } + + return sortObj; +} diff --git a/server/src/utils/responseUtils.ts b/server/src/utils/responseUtils.ts new file mode 100644 index 0000000000..03fe33b75d --- /dev/null +++ b/server/src/utils/responseUtils.ts @@ -0,0 +1,104 @@ +import type { Response } from 'express'; +import type { ApiResponse } from '../types/express'; +import type { PaginationMeta } from '../types/pagination.types'; + +function sendSuccess(res: Response, message: string, data?: unknown, statusCode = 200): void { + const response: ApiResponse = { + success: true, + message, + ...(data !== undefined && { data }), + }; + res.status(statusCode).json(response); +} + +function sendError(res: Response, message: string, error?: string, statusCode = 500): void { + const response: ApiResponse = { + success: false, + message, + ...(error !== undefined && { error }), + }; + res.status(statusCode).json(response); +} + +export const ResponseUtil = { + success(res: Response, message: string, data?: unknown, statusCode = 200): void { + sendSuccess(res, message, data, statusCode); + }, + + paginated( + res: Response, + message: string, + items: unknown[], + pagination: PaginationMeta, + dataKey = 'items' + ): void { + sendSuccess(res, message, { + [dataKey]: items, + pagination, + }); + }, + + error(res: Response, message: string, error?: string, statusCode = 500): void { + sendError(res, message, error, statusCode); + }, + + created(res: Response, message: string, data: unknown): void { + sendSuccess(res, message, data, 201); + }, + + notFound(res: Response, message = 'Resource not found'): void { + sendError(res, message, undefined, 404); + }, + + badRequest(res: Response, message: string, error?: string): void { + sendError(res, message, error, 400); + }, + + unauthorized(res: Response, message = 'Unauthorized'): void { + sendError(res, message, undefined, 401); + }, + + forbidden(res: Response, message = 'Forbidden'): void { + sendError(res, message, undefined, 403); + }, + + conflict(res: Response, message: string, error?: string): void { + sendError(res, message, error, 409); + }, + + rateLimitExceeded( + res: Response, + message = 'Too many requests. Please try again later', + error?: string + ): void { + sendError(res, message, error, 429); + }, + + validationError(res: Response, message: string, error?: string): void { + sendError(res, message, error, 422); + }, + + internalServerError(res: Response, message = 'Internal Server Error', error?: string): void { + sendError(res, message, error, 500); + }, + + serverUnavailable(res: Response, message = 'Service Unavailable', error?: string): void { + sendError(res, message, error, 503); + }, +}; + +export const internalServerError = ( + res: Response, + message = 'Internal Server Error', + err?: string +): void => { + sendError(res, message, err, 500); +}; + +export const serverUnavailable = ( + res: Response, + message = 'Service Unavailable', + err?: string +): void => { + sendError(res, message, err, 503); +}; diff --git a/server/src/utils/shutdownUtils.ts b/server/src/utils/shutdownUtils.ts new file mode 100644 index 0000000000..ec9e606906 --- /dev/null +++ b/server/src/utils/shutdownUtils.ts @@ -0,0 +1,125 @@ +import mongoose from 'mongoose'; +import type { Server } from 'http'; +import { stopEmailWorker } from '../workers/email.worker'; +import { logInfo, logWarn, logError } from '../utils/logger'; + +let isShuttingDown = false; +let getServerFn: (() => Server | null) | null = null; + +// Utility +const withTimeout = async (promise: Promise, ms: number, name: string): Promise => { + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`[Timeout] ${name} shutdown exceeded ${ms.toString()}ms`)); + }, ms); + }); + + try { + await Promise.race([promise, timeoutPromise]); + } catch (err) { + logError(`[server] ${name} failed to shut down cleanly`, { + module: 'shutdownUtils.ts/withTimeout', + error: (err as Error).message, + }); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +}; + +async function gracefulShutdown(signal: string, exitCode = 0): Promise { + if (isShuttingDown) return; + isShuttingDown = true; + + logInfo(`[server] ${signal} received. Initiating graceful shutdown...`); + + // Global Hard Timeout + const forceExitTimer = setTimeout(() => { + logError('[server] GLOBAL shutdown timeout reached (30s). Forcing exit.', { + module: 'shutdownUtils.ts/gracefulShutdown', + }); + process.exit(exitCode); + }, 30_000); + forceExitTimer.unref(); + + try { + const server = getServerFn ? getServerFn() : null; + + // STEP 1: HTTP Server (Max 5 seconds) + if (server) { + logInfo('[server] Stopping HTTP server...'); + + // Fatal crash + if (signal === 'Uncaught Exception' || signal === 'Unhandled Rejection') { + if ('closeAllConnections' in server) { + logWarn('[server] Fatal crash detected. Severing all active HTTP connections.'); + server.closeAllConnections(); + } + } else { + if ('closeIdleConnections' in server) { + server.closeIdleConnections(); + } + } + + const closeHttpPromise = new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + + await withTimeout(closeHttpPromise, 5000, 'HTTP Server'); + logInfo('[server] HTTP server closed.'); + } + + logInfo('[server] Stopping email worker...'); + await withTimeout(stopEmailWorker(), 3000, 'Email Worker'); + logInfo('[server] Email worker stopped.'); + + logInfo('[server] Closing MongoDB connection...'); + await withTimeout(mongoose.connection.close(false), 3000, 'MongoDB'); + logInfo('[server] MongoDB connection closed.'); + } catch (err) { + logError('[server] Unexpected error during shutdown sequence', { + module: 'shutdownUtils.ts/gracefulShutdown', + error: (err as Error).message, + }); + } + + logInfo('[server] Shutdown sequence complete.'); + process.exit(exitCode); +} + +export function setupGracefulShutdown(getServer: () => Server | null): void { + getServerFn = getServer; + + process.on('unhandledRejection', (reason) => { + logError('Unhandled Rejection', { + module: 'shutdownUtils.ts/setupGracefulShutdown', + reason: String(reason), + }); + void gracefulShutdown('Unhandled Rejection', 1); + }); + + process.on('uncaughtException', (error) => { + logError('Uncaught Exception', { + module: 'shutdownUtils.ts/setupGracefulShutdown', + error: error.message, + stack: error.stack, + }); + void gracefulShutdown('Uncaught Exception', 1); + }); + + process.on('SIGINT', () => { + void gracefulShutdown('SIGINT'); + }); + + process.on('SIGTERM', () => { + void gracefulShutdown('SIGTERM'); + }); +} diff --git a/server/src/utils/timeUtils.ts b/server/src/utils/timeUtils.ts new file mode 100644 index 0000000000..520b8a4a89 --- /dev/null +++ b/server/src/utils/timeUtils.ts @@ -0,0 +1,74 @@ +// Parse '60s'/'30m'/'24h'/'7d' or number → ms. Units: s,m,h,d. +export const parseDurationToMs = (duration: string | number): number => { + if (typeof duration === 'number') { + return duration; + } + if (/^\d+$/.test(duration)) { + return parseInt(duration, 10); + } + + const match = /^(\d+)([smhd])$/i.exec(duration); + if (!match) { + throw new Error( + `Invalid duration format: "${duration}". Expected format like "60s", "30m", "24h", "7d".` + ); + } + + const value = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + + switch (unit) { + case 's': + return value * 1000; + case 'm': + return value * 60 * 1000; + case 'h': + return value * 60 * 60 * 1000; + case 'd': + return value * 24 * 60 * 60 * 1000; + default: + return 0; + } +}; + +// Converts "09:30" to 570 +export const timeStringToMinutes = (timeString: string): number => { + const [hours, minutes] = timeString.split(':').map(Number); + if (isNaN(hours) || isNaN(minutes)) throw new Error(`Invalid time string: ${timeString}`); + return hours * 60 + minutes; +}; + +// Converts 570 to "09:30" +export const minutesToTimeString = (minutes: number): string => { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`; +}; + +// Collision formula +export const checkOverlap = ( + slotStart: number, + slotEnd: number, + conflicts: { start: number; end: number }[] +): boolean => { + return conflicts.some((conflict) => slotStart < conflict.end && slotEnd > conflict.start); +}; + +export const toLocalDateString = (date: Date): string => { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +// Check date within [tomorrow, tomorrow + 90 days] window +export const isBookableDate = (dateStr: string): boolean => { + const reqDate = new Date(dateStr + 'T00:00:00'); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const maxDate = new Date(today); + maxDate.setDate(maxDate.getDate() + 90); + return reqDate >= tomorrow && reqDate <= maxDate; +}; diff --git a/server/src/utils/tokenUtils.ts b/server/src/utils/tokenUtils.ts new file mode 100644 index 0000000000..a87f1f12a2 --- /dev/null +++ b/server/src/utils/tokenUtils.ts @@ -0,0 +1,181 @@ +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import mongoose from 'mongoose'; +import type { Response } from 'express'; +import { parseDurationToMs } from './timeUtils'; +import * as authRepo from '../modules/auth/auth.repository'; +import { authEnvs, jwtConfig } from '../constants/env'; +import type { TokenRevocationReasonType } from '../constants/auth.constants'; +import type { RefreshTokenPayload, TokenPayload } from '../types/express'; + +export const tokenVerifyOptions: jwt.VerifyOptions = { + issuer: jwtConfig.issuer, + audience: jwtConfig.audience, + algorithms: [jwtConfig.algorithm], +}; + +export const generateAccessToken = (userId: string, username: string, email: string): string => { + try { + const payload: TokenPayload = { + id: userId, + username, + email, + iat: Math.floor(Date.now() / 1000), + }; + + const jwtOptions: jwt.SignOptions = { + expiresIn: authEnvs.accessTokenExpiry as jwt.SignOptions['expiresIn'], + issuer: jwtConfig.issuer, + audience: jwtConfig.audience, + algorithm: jwtConfig.algorithm, + subject: userId, + }; + + if (!authEnvs.accessTokenSecret) { + throw new Error('Access token secret not defined'); + } + + return jwt.sign(payload, authEnvs.accessTokenSecret, jwtOptions); + } catch (error) { + throw new Error(`Failed to generate access token: ${(error as Error).message}`, { + cause: error, + }); + } +}; + +export interface GenerateRefreshTokenResult { + token: string; + rootTokenId: string; +} + +/** + * Generates a signed refresh token JWT and persists a hashed record to MongoDB. + * @param userId - Owner of the token. + * @param rootTokenId - ObjectId string of the family root. Omit for a new session (login). + * @param parentTokenId - ObjectId string of the token being replaced. Omit for login. + * @param session - Optional Mongoose ClientSession for transactional writes. + */ +export const generateRefreshToken = async ( + userId: string, + rootTokenId?: string, + parentTokenId?: string, + session?: mongoose.ClientSession +): Promise => { + try { + const jti = crypto.randomUUID(); + + const payload: RefreshTokenPayload = { + id: userId, + iat: Math.floor(Date.now() / 1000), + jti, + }; + + const jwtOptions: jwt.SignOptions = { + expiresIn: authEnvs.refreshTokenExpiry as jwt.SignOptions['expiresIn'], + issuer: jwtConfig.issuer, + audience: jwtConfig.audience, + algorithm: jwtConfig.algorithm, + subject: userId, + }; + + if (!authEnvs.refreshTokenSecret) { + throw new Error('Refresh token secret not defined'); + } + + const tokenId = new mongoose.Types.ObjectId(); + const effectiveRootTokenId = rootTokenId ? new mongoose.Types.ObjectId(rootTokenId) : tokenId; + const effectiveParentTokenId = parentTokenId + ? new mongoose.Types.ObjectId(parentTokenId) + : null; + + const token = jwt.sign(payload, authEnvs.refreshTokenSecret, jwtOptions); + const tokenHash = crypto.createHash('sha256').update(jti).digest('hex'); + const expiresAt = new Date(Date.now() + parseDurationToMs(authEnvs.refreshTokenExpiry)); + + await authRepo.createRefreshToken( + { + _id: tokenId, + userId: new mongoose.Types.ObjectId(userId), + tokenHash, + rootTokenId: effectiveRootTokenId, + parentTokenId: effectiveParentTokenId, + expiresAt, + }, + session + ); + + return { + token, + rootTokenId: effectiveRootTokenId.toString(), + }; + } catch (error) { + throw new Error(`Failed to generate refresh token: ${(error as Error).message}`, { + cause: error, + }); + } +}; + +// Revoke a single refresh token +export const revokeRefreshToken = async ( + tokenHash: string, + reason: TokenRevocationReasonType, + session?: mongoose.ClientSession +): Promise => { + try { + await authRepo.revokeRefreshToken(tokenHash, reason, session); + } catch (error) { + throw new Error(`Failed to revoke refresh token: ${(error as Error).message}`, { + cause: error, + }); + } +}; + +// Revoke a token family (same rootTokenId). +export const revokeTokenFamily = async ( + rootTokenId: mongoose.Types.ObjectId, + reason: TokenRevocationReasonType, + session?: mongoose.ClientSession +): Promise => { + try { + await authRepo.revokeTokenFamily(rootTokenId, reason, session); + } catch (error) { + throw new Error(`Failed to revoke token family: ${(error as Error).message}`, { + cause: error, + }); + } +}; + +// Cookie helpers +export const setTokenCookies = ( + res: Response, + accessToken: string, + refreshToken: string +): Response => { + const isProduction = process.env.NODE_ENV === 'production'; + const accessTokenMaxAge = parseDurationToMs(authEnvs.accessTokenExpiry); + const refreshTokenMaxAge = parseDurationToMs(authEnvs.refreshTokenExpiry); + + res.cookie('accessToken', accessToken, { + httpOnly: true, + secure: true, + sameSite: isProduction ? 'strict' : ('none' as const), + maxAge: accessTokenMaxAge, + path: '/', + }); + + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + secure: true, + sameSite: isProduction ? 'strict' : ('none' as const), + maxAge: refreshTokenMaxAge, + path: '/api/v1/auth', + }); + + return res; +}; + +export const clearTokenCookies = (res: Response): Response => { + res.clearCookie('accessToken', { path: '/' }); + res.clearCookie('refreshToken', { path: '/' }); + return res; +}; diff --git a/server/src/utils/winston/logger.ts b/server/src/utils/winston/logger.ts new file mode 100644 index 0000000000..070ff1b0f8 --- /dev/null +++ b/server/src/utils/winston/logger.ts @@ -0,0 +1,126 @@ +import winston from 'winston'; +import DailyRotateFile from 'winston-daily-rotate-file'; +import * as path from 'path'; +import * as fs from 'fs'; + +const logDirs = ['logs/combined', 'logs/error', 'logs/http']; +logDirs.forEach((dir) => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}); + +const levels: winston.config.AbstractConfigSetLevels = { + error: 0, + warn: 1, + info: 2, + http: 3, + verbose: 4, + debug: 5, + silly: 6, +}; + +const colors: winston.config.AbstractConfigSetColors = { + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + verbose: 'cyan', + debug: 'blue', + silly: 'gray', +}; + +winston.addColors(colors); + +// Shared format +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(), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + return JSON.stringify({ timestamp, level, message, ...meta }); + }) +); + +// Logger instance +const logger = winston.createLogger({ + levels, + format: logFormat, + transports: [ + // error and above → dedicated error log + new DailyRotateFile({ + filename: path.join('logs', 'error', '%DATE%.error.log'), + datePattern: 'MMMM', + level: 'error', + format: logFormat, + maxFiles: '12m', + createSymlink: true, + symlinkName: 'current.error.log', + }), + // everything → combined log + new DailyRotateFile({ + filename: path.join('logs', 'combined', '%DATE%.combined.log'), + datePattern: 'MMMM', + format: logFormat, + maxFiles: '12m', + createSymlink: true, + symlinkName: 'current.combined.log', + }), + // http and below → dedicated http log + new DailyRotateFile({ + filename: path.join('logs', 'http', '%DATE%.http.log'), + datePattern: 'MMMM', + level: 'http', + format: logFormat, + maxFiles: '12m', + createSymlink: true, + symlinkName: 'current.http.log', + }), + ], +}); + +// Colourised console output in non-production environments +if (process.env.NODE_ENV !== 'production') { + logger.add( + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize({ all: true }), + winston.format.simple() + ), + level: 'silly', + }) + ); +} + +// Typed helper functions + +export const logError = (message: string, meta?: Record): void => { + logger.error(message, meta); +}; + +export const logWarn = (message: string, meta?: Record): void => { + logger.warn(message, meta); +}; + +export const logInfo = (message: string, meta?: Record): void => { + logger.info(message, meta); +}; + +export const logHttp = (message: string, meta?: Record): void => { + logger.http(message, meta); +}; + +export const logDebug = (message: string, meta?: Record): void => { + logger.debug(message, meta); +}; + +export const logVerbose = (message: string, meta?: Record): void => { + logger.verbose(message, meta); +}; + +export const logSilly = (message: string, meta?: Record): void => { + logger.silly(message, meta); +}; + +export default logger; diff --git a/server/src/utils/winston/requestLogger.ts b/server/src/utils/winston/requestLogger.ts new file mode 100644 index 0000000000..e46eea4212 --- /dev/null +++ b/server/src/utils/winston/requestLogger.ts @@ -0,0 +1,25 @@ +import type { NextFunction, Request, Response } from 'express'; +import { logHttp } from './logger'; + +/** + * Express middleware that logs every completed HTTP request at the 'http' level. + * Attaches to res 'finish' so duration includes controller processing time. + * Register in server.ts before the main router. + */ +export const requestLogger = (req: Request, res: Response, next: NextFunction): void => { + const start = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - start; + logHttp(`${req.method} ${req.originalUrl}`, { + method: req.method, + url: req.originalUrl, + status: res.statusCode, + duration: `${String(duration)}ms`, + ip: req.ip, + userAgent: req.get('user-agent'), + }); + }); + + next(); +}; diff --git a/server/src/workers/banExpiry.worker.ts b/server/src/workers/banExpiry.worker.ts new file mode 100644 index 0000000000..e5913aa962 --- /dev/null +++ b/server/src/workers/banExpiry.worker.ts @@ -0,0 +1,45 @@ +import * as bannedUserService from '../modules/moderation/bannedUser.service'; +import { logInfo, logError } from '../utils/logger'; + +const POLL_INTERVAL_MS = 60000; // 1 minute +let banExpiryInterval: ReturnType | null = null; + +async function processExpiredBans(): Promise { + try { + const expiredCount = await bannedUserService.expireActiveBans(); + if (expiredCount > 0) { + logInfo('Ban expiry worker', { message: `Expired ${String(expiredCount)} ban records` }); + } + } catch (err) { + const error = err as Error; + logError('Ban expiry worker: failed to process expired bans', { + module: 'banExpiry.worker.ts/processExpiredBans', + error: error.message, + }); + } +} + +export function startBanExpiryWorker(): void { + if (banExpiryInterval) { + logInfo('Ban expiry worker already running'); + return; + } + + logInfo('Starting ban expiry worker'); + banExpiryInterval = setInterval(() => { + processExpiredBans().catch((err: unknown) => { + logError('Unexpected error in ban expiry worker', { + module: 'banExpiry.worker.ts', + error: err instanceof Error ? err.message : String(err), + }); + }); + }, POLL_INTERVAL_MS); +} + +export function stopBanExpiryWorker(): void { + if (banExpiryInterval) { + clearInterval(banExpiryInterval); + banExpiryInterval = null; + logInfo('Ban expiry worker stopped'); + } +} diff --git a/server/src/workers/bookingStatus.worker.ts b/server/src/workers/bookingStatus.worker.ts new file mode 100644 index 0000000000..9c987e2926 --- /dev/null +++ b/server/src/workers/bookingStatus.worker.ts @@ -0,0 +1,75 @@ +import { BookingModel } from '../modules/booking/models/booking.model.js'; +import { BookingStatus } from '../constants/booking.constants.js'; +import { logInfo } from '../utils/logger.js'; + +const POLL_INTERVAL_MS = 60_000; +let intervalHandle: ReturnType | null = null; + +export function startBookingStatusWorker(): void { + logInfo('Booking status worker started', { module: 'bookingStatus.worker' }); + void runOnce(); + intervalHandle = setInterval(() => { + void runOnce(); + }, POLL_INTERVAL_MS); +} + +async function runOnce(): Promise { + const now = new Date(); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const nowDate = now.toISOString().slice(0, 10); + + // 1. Confirmed bookings past endTime → completed + const pastConfirmed = await BookingModel.updateMany( + { + status: BookingStatus.CONFIRMED, + $or: [{ date: { $lt: nowDate } }, { date: nowDate, endTime: { $lte: nowMinutes } }], + }, + { $set: { status: BookingStatus.COMPLETED } } + ).lean(); + + if (pastConfirmed.modifiedCount > 0) { + logInfo(`Marked ${String(pastConfirmed.modifiedCount)} confirmed bookings as completed`, { + module: 'bookingStatus.worker', + }); + } + + // 2. Confirmed bookings currently active → in_progress + const activeConfirmed = await BookingModel.updateMany( + { + status: BookingStatus.CONFIRMED, + date: nowDate, + startTime: { $lte: nowMinutes }, + endTime: { $gt: nowMinutes }, + }, + { $set: { status: BookingStatus.IN_PROGRESS } } + ).lean(); + + if (activeConfirmed.modifiedCount > 0) { + logInfo(`Marked ${String(activeConfirmed.modifiedCount)} confirmed bookings as in_progress`, { + module: 'bookingStatus.worker', + }); + } + + // 3. In-progress bookings past endTime → completed + const pastInProgress = await BookingModel.updateMany( + { + status: BookingStatus.IN_PROGRESS, + $or: [{ date: { $lt: nowDate } }, { date: nowDate, endTime: { $lte: nowMinutes } }], + }, + { $set: { status: BookingStatus.COMPLETED } } + ).lean(); + + if (pastInProgress.modifiedCount > 0) { + logInfo(`Marked ${String(pastInProgress.modifiedCount)} in_progress bookings as completed`, { + module: 'bookingStatus.worker', + }); + } +} + +export function stopBookingStatusWorker(): void { + if (intervalHandle) { + clearInterval(intervalHandle); + intervalHandle = null; + logInfo('Booking status worker stopped', { module: 'bookingStatus.worker' }); + } +} diff --git a/server/src/workers/email.worker.ts b/server/src/workers/email.worker.ts new file mode 100644 index 0000000000..94044196af --- /dev/null +++ b/server/src/workers/email.worker.ts @@ -0,0 +1,340 @@ +import { EmailTaskModel, type IEmailTask } from '../models/email-task.model'; +import { emailService } from '../services/email.service'; +import { EmailIntent, EmailTaskStatus, EmailConstants } from '../constants/email.constants'; +import { logError, logWarn, logInfo } from '../utils/logger'; + +let isShuttingDown = false; +let pollingInterval: ReturnType | null = null; +let activeTasks = 0; + +const FIFTEEN_MIN_MS = 15 * 60 * 1000; +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +async function dispatch(task: IEmailTask): Promise { + const { intent, recipient, metadata } = task; + + switch (intent) { + case EmailIntent.PASSWORD_RESET: { + const resetLink = metadata.resetLink; + if (!resetLink) { + throw new Error(`Missing resetLink in metadata for ${EmailIntent.PASSWORD_RESET} intent`); + } + const result = await emailService.sendPasswordResetEmail(recipient, resetLink); + if (!result.success) { + throw new Error('Failed to send password reset email via Resend'); + } + break; + } + case EmailIntent.ADMIN_PASSWORD_RESET: { + const { newPassword, username } = metadata; + if (!newPassword || !username) { + throw new Error(`Missing required metadata for ${EmailIntent.ADMIN_PASSWORD_RESET} intent`); + } + const result = await emailService.sendAdminPasswordResetEmail( + recipient, + newPassword, + username + ); + if (!result.success) { + throw new Error('Failed to send admin password reset email via Resend'); + } + break; + } + case EmailIntent.SECURITY_ALERT: { + const result = await emailService.sendPasswordChangedEmail(recipient); + if (!result.success) { + throw new Error('Failed to send security alert email via Resend'); + } + break; + } + case EmailIntent.BOOKING_CONFIRMATION: { + const { venueName, date, startTime, endTime, amount, paymentReference } = metadata; + if (!venueName || !date || !startTime || !endTime || !amount || !paymentReference) { + throw new Error( + `Missing required metadata fields for ${EmailIntent.BOOKING_CONFIRMATION} intent` + ); + } + const result = await emailService.sendBookingConfirmation(recipient, { + venueName, + date, + startTime, + endTime, + amount: parseFloat(amount), + paymentReference, + }); + if (!result.success) { + throw new Error('Failed to send booking confirmation email via Resend'); + } + break; + } + case EmailIntent.BOOKING_REFUND: { + const { venueName, date, startTime, endTime, amount, refundReference } = metadata; + if (!venueName || !date || !startTime || !endTime || !amount || !refundReference) { + throw new Error( + `Missing required metadata fields for ${EmailIntent.BOOKING_REFUND} intent` + ); + } + const result = await emailService.sendRefundNotification(recipient, { + venueName, + date, + startTime, + endTime, + amount: parseFloat(amount), + refundReference, + }); + if (!result.success) { + throw new Error('Failed to send booking refund email via Resend'); + } + break; + } + case EmailIntent.BOOKING_CANCELLATION: { + const { venueName, date, timeRange, refundAmount, bookingRef } = metadata; + if (!venueName || !date || !timeRange || !refundAmount || !bookingRef) { + throw new Error( + `Missing required metadata fields for ${EmailIntent.BOOKING_CANCELLATION} intent` + ); + } + const result = await emailService.sendBookingCancellationEmail(recipient, { + venueName, + date, + timeRange, + refundAmount: parseFloat(refundAmount), + bookingRef, + }); + if (!result.success) { + throw new Error('Failed to send booking cancellation email via Resend'); + } + break; + } + case EmailIntent.VENUE_APPROVED: { + const { venueName } = metadata; + if (!venueName) { + throw new Error(`Missing venueName in metadata for ${EmailIntent.VENUE_APPROVED} intent`); + } + const result = await emailService.sendVenueApprovedEmail(recipient, venueName); + if (!result.success) { + throw new Error('Failed to send venue approved email via Resend'); + } + break; + } + case EmailIntent.VENUE_REJECTED: { + const { venueName, reason, editDeadline, submissionNumber } = metadata; + if (!venueName || !reason || !editDeadline || !submissionNumber) { + throw new Error( + `Missing required metadata fields for ${EmailIntent.VENUE_REJECTED} intent` + ); + } + const result = await emailService.sendVenueRejectedEmail( + recipient, + venueName, + reason, + new Date(editDeadline), + parseInt(submissionNumber, 10) + ); + if (!result.success) { + throw new Error('Failed to send venue rejected email via Resend'); + } + break; + } + case EmailIntent.VENUE_SUSPENDED: { + const { venueName, reason } = metadata; + if (!venueName || !reason) { + throw new Error( + `Missing required metadata fields for ${EmailIntent.VENUE_SUSPENDED} intent` + ); + } + const result = await emailService.sendVenueSuspendedEmail(recipient, venueName, reason); + if (!result.success) { + throw new Error('Failed to send venue suspended email via Resend'); + } + break; + } + case EmailIntent.VENUE_UNSUSPENDED: { + const { venueName } = metadata; + if (!venueName) { + throw new Error( + `Missing venueName in metadata for ${EmailIntent.VENUE_UNSUSPENDED} intent` + ); + } + const result = await emailService.sendVenueUnsuspendedEmail(recipient, venueName); + if (!result.success) { + throw new Error('Failed to send venue unsuspended email via Resend'); + } + break; + } + case EmailIntent.VENUE_DEADLINE_EXTENDED: { + const { venueName, newDeadline } = metadata; + if (!venueName || !newDeadline) { + throw new Error( + `Missing required metadata fields for ${EmailIntent.VENUE_DEADLINE_EXTENDED} intent` + ); + } + const result = await emailService.sendVenueDeadlineExtendedEmail( + recipient, + venueName, + new Date(newDeadline) + ); + if (!result.success) { + throw new Error('Failed to send venue deadline extended email via Resend'); + } + break; + } + case EmailIntent.USER_BANNED: { + const { scope, reason, expiresAt, venueName } = metadata; + if (!scope || !reason) { + throw new Error(`Missing required metadata fields for ${EmailIntent.USER_BANNED} intent`); + } + const result = await emailService.sendUserBannedEmail(recipient, { + scope, + reason, + expiresAt: expiresAt ? new Date(expiresAt) : null, + venueName: venueName || undefined, + }); + if (!result.success) { + throw new Error('Failed to send user banned email via Resend'); + } + break; + } + case EmailIntent.USER_UNBANNED: { + const result = await emailService.sendUserUnbannedEmail(recipient); + if (!result.success) { + throw new Error('Failed to send user unbanned email via Resend'); + } + break; + } + case EmailIntent.REVIEW_REMOVED: { + const { venueName, reason } = metadata; + if (!venueName || !reason) { + throw new Error( + `Missing required metadata fields for ${EmailIntent.REVIEW_REMOVED} intent` + ); + } + const result = await emailService.sendReviewRemovedEmail(recipient, { venueName, reason }); + if (!result.success) { + throw new Error('Failed to send review removed email via Resend'); + } + break; + } + case EmailIntent.REVIEW_RESTORED: { + const { venueName } = metadata; + if (!venueName) { + throw new Error(`Missing venueName in metadata for ${EmailIntent.REVIEW_RESTORED} intent`); + } + const result = await emailService.sendReviewRestoredEmail(recipient, venueName); + if (!result.success) { + throw new Error('Failed to send review restored email via Resend'); + } + break; + } + default: + throw new Error(`Unknown email intent: ${intent}`); + } +} + +async function processNextTask(): Promise { + if (isShuttingDown) return; + + const now = new Date(); + const staleCutoff = new Date(Date.now() - EmailConstants.STALE_CUTOFF_MS); + const pid = process.pid.toString(); + + try { + const task = await EmailTaskModel.findOneAndUpdate( + { + status: EmailTaskStatus.PENDING, + $or: [{ lockedAt: null }, { lockedAt: { $lt: staleCutoff } }], + retryAfter: { $lte: now }, + }, + { + $set: { + status: EmailTaskStatus.QUEUED, + workerId: pid, + lockedAt: now, + }, + }, + { returnDocument: 'after', writeConcern: { w: 'majority' } } + ); + + if (!task) return; + + activeTasks++; + + try { + await dispatch(task); + task.status = EmailTaskStatus.COMPLETED; + // Completed tasks expire after 15 minutes + task.lockedAt = null; + task.deleteAt = new Date(Date.now() + FIFTEEN_MIN_MS); + await task.save(); + logInfo(`Email task completed`, { taskId: task._id.toString() }); + } catch (e) { + const error = e as Error; + task.retries += 1; + task.lastError = error.message; + task.lockedAt = null; // Release lock so polling can re-acquire + + if (task.retries >= EmailConstants.MAX_RETRIES) { + task.status = EmailTaskStatus.FAILED; + // Failed tasks persist for 7 days for debugging + task.deleteAt = new Date(Date.now() + SEVEN_DAYS_MS); + logError(`Email task failed permanently`, { + module: 'email.worker.ts/processNextTask', + taskId: task._id.toString(), + error: error.message, + }); + } else { + task.status = EmailTaskStatus.PENDING; + // Fix: schedule retry via retryAfter, not lockedAt + const backoffMs = 2 ** task.retries * 30000; // 30s, 60s, 120s... + task.retryAfter = new Date(Date.now() + backoffMs); + // Failed retry tasks keep the 7-day deleteAt from creation + logWarn(`Email task failed, scheduling retry`, { + taskId: task._id.toString(), + retryInSeconds: backoffMs / 1000, + error: error.message, + }); + } + + await task.save(); + } finally { + activeTasks--; + } + } catch (err) { + logError('Email worker polling loop encountered an error', { + module: 'email.worker.ts/processNextTask', + error: (err as Error).message, + }); + } +} + +export function startEmailWorker(): void { + if (pollingInterval) return; + logInfo('Email worker started', { pollIntervalSeconds: EmailConstants.POLL_INTERVAL_MS / 1000 }); + pollingInterval = setInterval(() => void processNextTask(), EmailConstants.POLL_INTERVAL_MS); +} + +export async function stopEmailWorker(): Promise { + logInfo('Email worker shutdown initiated'); + isShuttingDown = true; + + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + } + + // Wait for in-flight tasks to complete (max 30 seconds) + const waitInterval = 500; + let waited = 0; + const maxWait = 30000; + + while (activeTasks > 0 && waited < maxWait) { + await new Promise((resolve) => setTimeout(resolve, waitInterval)); + waited += waitInterval; + } + + if (activeTasks > 0) { + logWarn('Email worker shutdown complete with incomplete tasks', { activeTasks }); + } else { + logInfo('Email worker shutdown complete gracefully'); + } +} diff --git a/server/src/workers/venueEditDeadline.worker.ts b/server/src/workers/venueEditDeadline.worker.ts new file mode 100644 index 0000000000..cdf08b746b --- /dev/null +++ b/server/src/workers/venueEditDeadline.worker.ts @@ -0,0 +1,115 @@ +import { VenueModel } from '../modules/venue/venue.model'; +import { UserModel } from '../modules/user/user.models'; +import { logModerationAction } from '../modules/moderation/moderationActivity.service'; +import { emailService } from '../services/email.service'; +import { logInfo, logError } from '../utils/logger'; +import { VENUE_CONSTANTS } from '../constants/venue.constants'; +import mongoose from 'mongoose'; + +let superAdminId: string | null = null; + +async function getSuperAdminId(): Promise { + if (superAdminId) return superAdminId; + const admin = await UserModel.findOne({ role: 'superAdmin' }).select('_id').lean(); + if (!admin) throw new Error('SuperAdmin not found for auto-suspend worker'); + superAdminId = admin._id.toString(); + return superAdminId; +} + +export async function checkAndSuspendExpiredVenues(): Promise { + const now = new Date(); + const systemId = await getSuperAdminId(); + + const expiredVenues = await VenueModel.find({ + status: 'Rejected', + currentEditDeadline: { $lt: now }, + deleted: false, + }) + .select('_id name ownerUserId currentEditDeadline') + .lean(); + + let suspendedCount = 0; + + for (const venue of expiredVenues) { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + // Double-check status hasn't changed (race condition protection) + const freshVenue = await VenueModel.findById(venue._id).session(session); + if ( + freshVenue?.status !== 'Rejected' || + !freshVenue.currentEditDeadline || + freshVenue.currentEditDeadline >= now + ) { + await session.abortTransaction(); + continue; + } + + await VenueModel.findByIdAndUpdate( + venue._id, + { + status: 'Suspended', + suspensionReason: VENUE_CONSTANTS.AUTO_SUSPEND_REASON, + currentEditDeadline: null, + updatedBy: new mongoose.Types.ObjectId(systemId), + }, + { session } + ).exec(); + + // Log activity with system actor + const deadlineStr = venue.currentEditDeadline?.toISOString() ?? 'unknown'; + await logModerationAction( + systemId, + 'auto_suspend_venue', + venue._id.toString(), + 'venue', + `Auto-suspended after edit window expired on ${deadlineStr}`, + { actor: 'system (superadmin)' } + ); + + // Email owner + const owner = await UserModel.findById(venue.ownerUserId).select('email').lean(); + if (owner?.email) { + const daysStr = VENUE_CONSTANTS.EDIT_WINDOW_DAYS.toString(); + await emailService.sendVenueSuspendedEmail( + owner.email, + venue.name, + `Your venue was auto-suspended because it was not resubmitted within ${daysStr} days of rejection.` + ); + } + + await session.commitTransaction(); + suspendedCount++; + } catch (err: unknown) { + await session.abortTransaction(); + logError('Failed to auto-suspend venue', { venueId: venue._id, error: err }); + } finally { + await session.endSession(); + } + } + + if (suspendedCount > 0) { + logInfo('Auto-suspended venues due to edit window expiry', { count: suspendedCount }); + } + + return suspendedCount; +} + +export function startAutoSuspendWorker(): void { + // Run every 6 hours + setInterval( + () => { + void checkAndSuspendExpiredVenues().catch((err: unknown) => { + logError('Auto-suspend worker error', { error: err }); + }); + }, + 6 * 60 * 60 * 1000 + ); + // Also run once on startup after a short delay + setTimeout(() => { + void checkAndSuspendExpiredVenues().catch((err: unknown) => { + logError('Auto-suspend worker startup error', { error: err }); + }); + }, 5000); +} diff --git a/server/tests/globalSetup.ts b/server/tests/globalSetup.ts new file mode 100644 index 0000000000..536ffa0664 --- /dev/null +++ b/server/tests/globalSetup.ts @@ -0,0 +1,38 @@ +import { MongoMemoryReplSet } from 'mongodb-memory-server'; + +let replSet: MongoMemoryReplSet; + +export async function setup() { + process.env.NODE_ENV = 'test'; + process.env.JWT_ACCESS_SECRET = 'test_jwt_access_secret_key_1234567890'; + process.env.JWT_REFRESH_SECRET = 'test_jwt_refresh_secret_key_1234567890'; + process.env.ACCESS_TOKEN_EXPIRY = '30m'; + process.env.REFRESH_TOKEN_EXPIRY = '7d'; + process.env.RAZORPAY_WEBHOOK_SECRET = 'test_webhook_secret_1234567890'; + + replSet = await MongoMemoryReplSet.create({ + replSet: { + count: 1, + dbName: 'test', + storageEngine: 'wiredTiger', + }, + binary: { + version: '6.0.14', + }, + }); + + const uri = replSet.getUri(); + process.env.MONGODB_URI = uri; + + return async () => { + if (replSet) { + await replSet.stop(); + } + }; +} + +export async function teardown() { + if (replSet) { + await replSet.stop(); + } +} diff --git a/server/tests/helpers/auth.helper.ts b/server/tests/helpers/auth.helper.ts new file mode 100644 index 0000000000..166a083503 --- /dev/null +++ b/server/tests/helpers/auth.helper.ts @@ -0,0 +1,22 @@ +import { generateAccessToken } from '../../src/utils/tokenUtils'; +import { createTestUser } from './db.helper'; +import type { IUser } from '../../src/modules/user/user.models'; + +export interface AuthenticatedUserSession { + user: IUser; + accessToken: string; + authHeader: { Authorization: string }; + cookieHeader: string; +} + +export const createAuthenticatedSession = async (): Promise => { + const user = await createTestUser(); + const accessToken = generateAccessToken(user.id as string, user.username, user.email); + + return { + user, + accessToken, + authHeader: { Authorization: `Bearer ${accessToken}` }, + cookieHeader: `accessToken=${accessToken}`, + }; +}; diff --git a/server/tests/helpers/db.helper.ts b/server/tests/helpers/db.helper.ts new file mode 100644 index 0000000000..d84cb13149 --- /dev/null +++ b/server/tests/helpers/db.helper.ts @@ -0,0 +1,29 @@ +import bcrypt from 'bcrypt'; +import { UserModel, type IUser } from '../../src/modules/user/user.models'; + +export interface CreateTestUserOptions { + username?: string; + email?: string; + password?: string; + active?: boolean; + deleted?: boolean; + isBanned?: boolean; +} + +export const createTestUser = async (options: CreateTestUserOptions = {}): Promise => { + const username = options.username ?? `testuser_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + const email = options.email ?? `${username}@example.com`; + const plainPassword = options.password ?? 'Password@123'; + const hashedPassword = await bcrypt.hash(plainPassword, 10); + + const user = new UserModel({ + username, + email, + password: hashedPassword, + active: options.active ?? true, + deleted: options.deleted ?? false, + isBanned: options.isBanned ?? false, + }); + + return user.save(); +}; diff --git a/server/tests/helpers/webhook.helper.ts b/server/tests/helpers/webhook.helper.ts new file mode 100644 index 0000000000..97816b62ab --- /dev/null +++ b/server/tests/helpers/webhook.helper.ts @@ -0,0 +1,67 @@ +import crypto from 'crypto'; + +export const TEST_WEBHOOK_SECRET = 'test_webhook_secret_1234567890'; + +export function generateRazorpaySignature( + rawBody: string, + secret = TEST_WEBHOOK_SECRET +): string { + return crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); +} + +export function createMockRazorpayPaymentCapturedPayload(options: { + paymentId?: string; + orderId?: string; + amount?: number; + notes?: Record; + event?: string; +} = {}): object { + const paymentId = options.paymentId ?? `pay_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + const orderId = options.orderId ?? `order_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + const amount = options.amount ?? 50000; + + return { + entity: 'event', + account_id: 'acc_test123', + event: options.event ?? 'payment.captured', + contains: ['payment'], + payload: { + payment: { + entity: { + id: paymentId, + entity: 'payment', + amount, + currency: 'INR', + status: 'captured', + order_id: orderId, + invoice_id: null, + international: false, + method: 'card', + amount_refunded: 0, + refund_status: null, + captured: true, + description: 'Booking Payment', + card_id: 'card_test123', + bank: null, + wallet: null, + vpa: null, + email: 'testuser@example.com', + contact: '+919999999999', + notes: options.notes ?? { + holdId: 'hold_test_123', + venueId: '60d5ecb8b392d40015f8a001', + date: '2026-08-01', + slotId: '09:00-11:00', + userId: '60d5ecb8b392d40015f8a002', + }, + fee: 1000, + tax: 180, + error_code: null, + error_description: null, + created_at: Math.floor(Date.now() / 1000), + }, + }, + }, + created_at: Math.floor(Date.now() / 1000), + }; +} diff --git a/server/tests/integration/middlewares/validation.middleware.test.ts b/server/tests/integration/middlewares/validation.middleware.test.ts new file mode 100644 index 0000000000..1286de6834 --- /dev/null +++ b/server/tests/integration/middlewares/validation.middleware.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { z } from 'zod'; +import { validateBody, validateParams, validateQuery } from '../../../src/middlewares/validation.middleware'; + +describe('Validation Middleware', () => { + const app = express(); + app.use(express.json()); + + const sampleSchema = z.object({ + name: z.string().min(3), + age: z.number().min(18), + }); + + const paramsSchema = z.object({ + id: z.string().min(5), + }); + + const querySchema = z.object({ + page: z.string().transform(Number), + }); + + app.post('/test-body', validateBody(sampleSchema), (req, res) => { + res.status(200).json({ success: true, validated: req.validated?.body }); + }); + + app.get('/test-params/:id', validateParams(paramsSchema), (req, res) => { + res.status(200).json({ success: true, validated: req.validated?.params }); + }); + + app.get('/test-query', validateQuery(querySchema), (req, res) => { + res.status(200).json({ success: true, validated: req.validated?.query }); + }); + + it('should allow valid request body and populate req.validated', async () => { + const response = await request(app) + .post('/test-body') + .send({ name: 'Alice', age: 25 }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.validated).toEqual({ name: 'Alice', age: 25 }); + }); + + it('should reject invalid request body with HTTP 400', async () => { + const response = await request(app) + .post('/test-body') + .send({ name: 'Al', age: 15 }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it('should validate params correctly', async () => { + const response = await request(app).get('/test-params/12345'); + expect(response.status).toBe(200); + + const invalidResponse = await request(app).get('/test-params/12'); + expect(invalidResponse.status).toBe(400); + }); + + it('should validate and transform query params', async () => { + const response = await request(app).get('/test-query?page=2'); + expect(response.status).toBe(200); + expect(response.body.validated).toEqual({ page: 2 }); + }); +}); diff --git a/server/tests/integration/routes/auth.test.ts b/server/tests/integration/routes/auth.test.ts new file mode 100644 index 0000000000..3ba7165391 --- /dev/null +++ b/server/tests/integration/routes/auth.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import { app } from '../../../src/app'; +import { createTestUser } from '../../helpers/db.helper'; + +describe('Auth API Routes', () => { + describe('POST /api/v1/auth/register', () => { + it('should register a new user successfully', async () => { + const response = await request(app) + .post('/api/v1/auth/register') + .send({ + username: 'newuser123', + email: 'newuser123@example.com', + password: 'Password@123', + }); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + }); + + it('should return 400 when invalid registration data is provided', async () => { + const response = await request(app) + .post('/api/v1/auth/register') + .send({ + username: 'usr', + email: 'invalid-email', + password: '123', + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + }); + + describe('POST /api/v1/auth/login', () => { + it('should log in successfully with valid credentials', async () => { + const password = 'Password@123'; + const user = await createTestUser({ password }); + + const response = await request(app) + .post('/api/v1/auth/login') + .send({ + email: user.email, + password, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.headers['set-cookie']).toBeDefined(); + }); + + it('should reject login with wrong password', async () => { + const user = await createTestUser({ password: 'Password@123' }); + + const response = await request(app) + .post('/api/v1/auth/login') + .send({ + email: user.email, + password: 'WrongPassword@123', + }); + + expect(response.status).toBe(401); + expect(response.body.success).toBe(false); + }); + }); + + describe('POST /api/v1/auth/refresh', () => { + it('should return 401 when refresh token cookie is missing', async () => { + const response = await request(app).post('/api/v1/auth/refresh'); + expect(response.status).toBe(401); + expect(response.body.success).toBe(false); + }); + + it('should refresh access token when valid refresh cookie is sent', async () => { + const password = 'Password@123'; + const user = await createTestUser({ password }); + + // Perform login to get cookies + const loginRes = await request(app) + .post('/api/v1/auth/login') + .send({ email: user.email, password }); + + const cookies = loginRes.headers['set-cookie'] as string[]; + expect(cookies).toBeDefined(); + + const refreshRes = await request(app) + .post('/api/v1/auth/refresh') + .set('Cookie', cookies); + + expect(refreshRes.status).toBe(200); + expect(refreshRes.body.success).toBe(true); + }); + }); + + describe('POST /api/v1/auth/logout', () => { + it('should log out user and clear session cookies', async () => { + const password = 'Password@123'; + const user = await createTestUser({ password }); + + const loginRes = await request(app) + .post('/api/v1/auth/login') + .send({ email: user.email, password }); + + const cookies = loginRes.headers['set-cookie'] as string[]; + + const logoutRes = await request(app) + .post('/api/v1/auth/logout') + .set('Cookie', cookies); + + expect(logoutRes.status).toBe(200); + expect(logoutRes.body.success).toBe(true); + }); + }); + + describe('POST /api/v1/auth/forgot-password', () => { + it('should accept valid email and return success', async () => { + const user = await createTestUser(); + + const response = await request(app) + .post('/api/v1/auth/forgot-password') + .send({ email: user.email }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it('should return 400 for invalid email format', async () => { + const response = await request(app) + .post('/api/v1/auth/forgot-password') + .send({ email: 'not-an-email' }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + }); + + describe('GET /api/v1/auth/sessions', () => { + it('should list active sessions for logged in user', async () => { + const password = 'Password@123'; + const user = await createTestUser({ password }); + + const loginRes = await request(app) + .post('/api/v1/auth/login') + .send({ email: user.email, password }); + + const cookies = loginRes.headers['set-cookie'] as string[]; + + const sessionsRes = await request(app) + .get('/api/v1/auth/sessions') + .set('Cookie', cookies); + + expect(sessionsRes.status).toBe(200); + expect(sessionsRes.body.success).toBe(true); + expect(Array.isArray(sessionsRes.body.data)).toBe(true); + }); + }); +}); diff --git a/server/tests/integration/routes/booking.test.ts b/server/tests/integration/routes/booking.test.ts new file mode 100644 index 0000000000..df3a5333dc --- /dev/null +++ b/server/tests/integration/routes/booking.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import { app } from '../../../src/app'; +import { createAuthenticatedSession } from '../../helpers/auth.helper'; +import { IdempotencyKeyModel } from '../../../src/models/idempotency-key.model'; + +describe('Booking API Routes', () => { + describe('GET /api/v1/bookings/my-bookings', () => { + it('should return 401 when not authenticated', async () => { + const response = await request(app).get('/api/v1/bookings/my-bookings'); + expect(response.status).toBe(401); + expect(response.body.success).toBe(false); + }); + + it('should return user bookings list when authenticated', async () => { + const session = await createAuthenticatedSession(); + + const response = await request(app) + .get('/api/v1/bookings/my-bookings') + .set('Cookie', [session.cookieHeader]); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it('should accept pagination and status filter query parameters', async () => { + const session = await createAuthenticatedSession(); + + const response = await request(app) + .get('/api/v1/bookings/my-bookings?page=1&limit=5&status=confirmed') + .set('Cookie', [session.cookieHeader]); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + }); + + describe('POST /api/v1/bookings/checkout', () => { + it('should return 400 or unauthorized/forbidden when lockId payload is invalid', async () => { + const session = await createAuthenticatedSession(); + + const response = await request(app) + .post('/api/v1/bookings/checkout') + .set('Cookie', [session.cookieHeader]) + .send({ lockId: 'invalid-id' }); + + expect([400, 401, 403]).toContain(response.status); + expect(response.body.success).toBe(false); + }); + }); + + describe('Idempotency-Key handling on POST /api/v1/bookings/checkout', () => { + it('should cache the response and replay it on the same key', async () => { + const session = await createAuthenticatedSession(); + const key = 'booking-test-replay-key'; + + const first = await request(app) + .post('/api/v1/bookings/checkout') + .set('Cookie', [session.cookieHeader]) + .set('Idempotency-Key', key) + .send({ lockId: 'invalid-id' }); + + expect([400, 401, 403]).toContain(first.status); + expect(first.body.success).toBe(false); + + const cacheEntry = await IdempotencyKeyModel.findOne({ key }).lean().exec(); + expect(cacheEntry).toBeTruthy(); + expect(cacheEntry?.response.status).toBe(first.status); + + const second = await request(app) + .post('/api/v1/bookings/checkout') + .set('Cookie', [session.cookieHeader]) + .set('Idempotency-Key', key) + .send({ lockId: 'invalid-id' }); + + expect(second.status).toBe(first.status); + expect(second.body).toEqual(first.body); + }); + + it('should pass through when no Idempotency-Key header is present', async () => { + const session = await createAuthenticatedSession(); + + const response = await request(app) + .post('/api/v1/bookings/checkout') + .set('Cookie', [session.cookieHeader]) + .send({ lockId: 'invalid-id' }); + + expect([400, 401, 403]).toContain(response.status); + expect(response.body.success).toBe(false); + }); + + it('should treat different idempotency keys as independent requests', async () => { + const session = await createAuthenticatedSession(); + + const responseA = await request(app) + .post('/api/v1/bookings/checkout') + .set('Cookie', [session.cookieHeader]) + .set('Idempotency-Key', 'key-independent-a') + .send({ lockId: 'invalid-id' }); + + expect([400, 401, 403]).toContain(responseA.status); + + const responseB = await request(app) + .post('/api/v1/bookings/checkout') + .set('Cookie', [session.cookieHeader]) + .set('Idempotency-Key', 'key-independent-b') + .send({ lockId: 'invalid-id' }); + + expect([400, 401, 403]).toContain(responseB.status); + + const count = await IdempotencyKeyModel.countDocuments({ + key: { $in: ['key-independent-a', 'key-independent-b'] }, + }); + expect(count).toBe(2); + }); + }); + + describe('GET /api/v1/bookings/:bookingRefId', () => { + it('should return 404 or authorization error for a non-existent booking reference ID', async () => { + const session = await createAuthenticatedSession(); + const fakeRefId = 'BMV-999999'; + + const response = await request(app) + .get(`/api/v1/bookings/${fakeRefId}`) + .set('Cookie', [session.cookieHeader]); + + expect([401, 403, 404]).toContain(response.status); + }); + }); +}); diff --git a/server/tests/integration/routes/health.test.ts b/server/tests/integration/routes/health.test.ts new file mode 100644 index 0000000000..31332c1294 --- /dev/null +++ b/server/tests/integration/routes/health.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import { app } from '../../../src/app'; + +describe('GET /api/v1/health', () => { + it('should return 200 OK with health status message', async () => { + const response = await request(app).get('/api/v1/health'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.message).toBe('Service is healthy'); + }); + + it('should return 404 for unknown endpoints', async () => { + const response = await request(app).get('/api/v1/unknown-endpoint-xyz'); + + expect(response.status).toBe(404); + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('Route/Method not found'); + }); +}); diff --git a/server/tests/integration/routes/rbac.test.ts b/server/tests/integration/routes/rbac.test.ts new file mode 100644 index 0000000000..c01ad95343 --- /dev/null +++ b/server/tests/integration/routes/rbac.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import { app } from '../../../src/app'; +import { createAuthenticatedSession } from '../../helpers/auth.helper'; + +describe('RBAC & Authorization Middleware API Routes', () => { + describe('Admin-only Route Restrictions', () => { + it('should reject unauthenticated request with 401 Unauthorized', async () => { + const response = await request(app).get('/api/v1/bookings/all'); + expect(response.status).toBe(401); + expect(response.body.success).toBe(false); + }); + + it('should deny non-admin standard user with 401/403 on admin booking list', async () => { + const session = await createAuthenticatedSession(); + + const response = await request(app) + .get('/api/v1/bookings/all') + .set('Cookie', [session.cookieHeader]); + + expect([401, 403]).toContain(response.status); + expect(response.body.success).toBe(false); + }); + + it('should deny non-admin standard user with 401/403 on ban user endpoint', async () => { + const session = await createAuthenticatedSession(); + + const response = await request(app) + .post('/api/v1/user/60d5ecb8b392d40015f8a999/ban') + .set('Cookie', [session.cookieHeader]) + .send({ banReason: 'Violation of terms' }); + + expect([401, 403]).toContain(response.status); + expect(response.body.success).toBe(false); + }); + }); +}); diff --git a/server/tests/integration/routes/user.test.ts b/server/tests/integration/routes/user.test.ts new file mode 100644 index 0000000000..7a3a9a596f --- /dev/null +++ b/server/tests/integration/routes/user.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import { app } from '../../../src/app'; +import { createAuthenticatedSession } from '../../helpers/auth.helper'; + +describe('User API Routes', () => { + describe('GET /api/v1/user/profile', () => { + it('should return 401 when access token cookie is missing', async () => { + const response = await request(app).get('/api/v1/user/profile'); + expect(response.status).toBe(401); + expect(response.body.success).toBe(false); + }); + + it('should return authenticated user profile when cookie is present', async () => { + const session = await createAuthenticatedSession(); + + const response = await request(app) + .get('/api/v1/user/profile') + .set('Cookie', [session.cookieHeader]); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.email).toBe(session.user.email); + expect(response.body.data.name).toBe(session.user.username); + }); + }); + + describe('PATCH /api/v1/user/profile', () => { + it('should update user profile successfully', async () => { + const session = await createAuthenticatedSession(); + + const response = await request(app) + .patch('/api/v1/user/profile') + .set('Cookie', [session.cookieHeader]) + .send({ + username: 'updatedname', + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.name).toBe('updatedname'); + }); + }); +}); diff --git a/server/tests/integration/routes/venue.test.ts b/server/tests/integration/routes/venue.test.ts new file mode 100644 index 0000000000..0b97ba56a2 --- /dev/null +++ b/server/tests/integration/routes/venue.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import { app } from '../../../src/app'; + +describe('Venue API Routes', () => { + describe('GET /api/v1/venues', () => { + it('should return paginated active venues list', async () => { + const response = await request(app).get('/api/v1/venues'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data.venues)).toBe(true); + }); + + it('should accept pagination and filter query parameters', async () => { + const response = await request(app).get('/api/v1/venues?page=1&limit=5&city=Mumbai'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + }); + + describe('GET /api/v1/venues/pins', () => { + it('should return empty venue pins array when no venues exist', async () => { + const response = await request(app).get('/api/v1/venues/pins'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + }); + }); + + describe('GET /api/v1/venues/featured', () => { + it('should return featured venues array', async () => { + const response = await request(app).get('/api/v1/venues/featured'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + }); + }); +}); diff --git a/server/tests/integration/routes/webhook.test.ts b/server/tests/integration/routes/webhook.test.ts new file mode 100644 index 0000000000..407a0cc401 --- /dev/null +++ b/server/tests/integration/routes/webhook.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import { app } from '../../../src/app'; +import { ProcessedWebhookModel } from '../../../src/modules/booking/models/processedWebhook.model'; +import { + generateRazorpaySignature, + createMockRazorpayPaymentCapturedPayload, + TEST_WEBHOOK_SECRET, +} from '../../helpers/webhook.helper'; + +// Ensure the unique index on ProcessedWebhooks.eventId is created before tests run. +// mongodb-memory-server may create indexes asynchronously, causing the idempotency +// gate to miss duplicate key errors in the duplicate webhook test. +beforeAll(async () => { + await ProcessedWebhookModel.createIndexes(); +}); + +describe('Razorpay Webhook API Routes', () => { + describe('POST /api/v1/webhook/razorpay', () => { + it('should reject requests missing the x-razorpay-signature header', async () => { + const payload = createMockRazorpayPaymentCapturedPayload(); + const rawBody = JSON.stringify(payload); + + const response = await request(app) + .post('/api/v1/webhook/razorpay') + .set('Content-Type', 'application/json') + .send(rawBody); + + expect(response.status).toBe(200); + expect(response.body.received).toBe(false); + expect(response.body.error).toBe('Missing x-razorpay-signature header'); + }); + + it('should reject requests with invalid HMAC signatures', async () => { + const payload = createMockRazorpayPaymentCapturedPayload(); + const rawBody = JSON.stringify(payload); + const invalidSignature = 'invalid_hmac_signature_hash_1234567890'; + + const response = await request(app) + .post('/api/v1/webhook/razorpay') + .set('Content-Type', 'application/json') + .set('x-razorpay-signature', invalidSignature) + .send(rawBody); + + expect(response.status).toBe(200); + expect(response.body.received).toBe(false); + expect(response.body.error).toBe('Invalid webhook signature'); + }); + + it('should ignore non-payment.captured events', async () => { + const payload = createMockRazorpayPaymentCapturedPayload({ event: 'payment.failed' }); + const rawBody = JSON.stringify(payload); + const signature = generateRazorpaySignature(rawBody, TEST_WEBHOOK_SECRET); + + const response = await request(app) + .post('/api/v1/webhook/razorpay') + .set('Content-Type', 'application/json') + .set('x-razorpay-signature', signature) + .send(rawBody); + + expect(response.status).toBe(200); + expect(response.body.received).toBe(true); + }); + + it('should return 200 (with error) for a valid webhook with invalid notes metadata', async () => { + const payload = createMockRazorpayPaymentCapturedPayload({ + notes: { foo: 'bar' }, + }); + const rawBody = JSON.stringify(payload); + const signature = generateRazorpaySignature(rawBody, TEST_WEBHOOK_SECRET); + + const response = await request(app) + .post('/api/v1/webhook/razorpay') + .set('Content-Type', 'application/json') + .set('x-razorpay-signature', signature) + .send(rawBody); + + expect(response.status).toBe(200); + expect(response.body.received).toBe(true); + }); + + it('should handle duplicate webhook events idempotently', async () => { + const payload = createMockRazorpayPaymentCapturedPayload(); + const rawBody = JSON.stringify(payload); + const signature = generateRazorpaySignature(rawBody, TEST_WEBHOOK_SECRET); + + // First request — process normally + const firstResponse = await request(app) + .post('/api/v1/webhook/razorpay') + .set('Content-Type', 'application/json') + .set('x-razorpay-signature', signature) + .send(rawBody); + + expect(firstResponse.status).toBe(200); + expect(firstResponse.body.received).toBe(true); + + // Second request — should be detected as duplicate and return idempotent: true + const secondResponse = await request(app) + .post('/api/v1/webhook/razorpay') + .set('Content-Type', 'application/json') + .set('x-razorpay-signature', signature) + .send(rawBody); + + expect(secondResponse.status).toBe(200); + expect(secondResponse.body.idempotent).toBe(true); + }); + }); +}); diff --git a/server/tests/setup.ts b/server/tests/setup.ts new file mode 100644 index 0000000000..af9884d75c --- /dev/null +++ b/server/tests/setup.ts @@ -0,0 +1,64 @@ +import { beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; +import mongoose from 'mongoose'; +import { RoleModel } from '../src/models/role.model'; + +beforeAll(async () => { + process.env.NODE_ENV = 'test'; + process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test_jwt_access_secret_key_1234567890'; + process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test_jwt_refresh_secret_key_1234567890'; + process.env.ACCESS_TOKEN_EXPIRY = '30m'; + process.env.REFRESH_TOKEN_EXPIRY = '7d'; + process.env.RAZORPAY_WEBHOOK_SECRET = process.env.RAZORPAY_WEBHOOK_SECRET || 'test_webhook_secret_1234567890'; + + const baseUri = process.env.MONGODB_URI; + if (!baseUri) { + throw new Error('MONGODB_URI environment variable is not defined by global setup'); + } + + const workerId = process.env.VITEST_POOL_ID || '1'; + const url = new URL(baseUri); + url.pathname = `/test_db_${workerId}`; + url.searchParams.set('retryWrites', 'false'); + const uri = url.toString(); + + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + await mongoose.connect(uri, { retryWrites: false }); +}, 60000); + +beforeEach(async () => { + if (mongoose.connection.readyState !== 0) { + await RoleModel.updateOne( + { name: 'user' }, + { + $setOnInsert: { + name: 'user', + displayName: 'User', + description: 'Standard registered user', + isSystem: true, + priority: 100, + active: true, + deleted: false, + }, + }, + { upsert: true } + ); + } +}); + +afterEach(async () => { + if (mongoose.connection.db) { + const collections = await mongoose.connection.db.collections(); + for (const collection of collections) { + await collection.deleteMany({}); + } + } +}); + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } +}, 60000); + diff --git a/server/tests/unit/idempotency.middleware.test.ts b/server/tests/unit/idempotency.middleware.test.ts new file mode 100644 index 0000000000..da4643f9c9 --- /dev/null +++ b/server/tests/unit/idempotency.middleware.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { idempotencyMiddleware } from '../../src/middlewares/idempotency.middleware'; +import { IdempotencyKeyModel } from '../../src/models/idempotency-key.model'; + +describe('idempotencyMiddleware', () => { + beforeEach(async () => { + await IdempotencyKeyModel.deleteMany({}); + }); + + it('should pass through when no Idempotency-Key header is present', async () => { + const app = express(); + app.use(express.json()); + app.post('/test', idempotencyMiddleware(), (_req, res) => { + res.status(200).json({ success: true }); + }); + + const response = await request(app).post('/test').send({}); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it('should cache the response and replay it on the same key', async () => { + const app = express(); + app.use(express.json()); + app.post('/test', idempotencyMiddleware(), (_req, res) => { + res.status(422).json({ success: false, message: 'validation error' }); + }); + + const first = await request(app) + .post('/test') + .set('Idempotency-Key', 'unit-test-replay') + .send({}); + + expect(first.status).toBe(422); + expect(first.body).toEqual({ success: false, message: 'validation error' }); + + const cacheEntry = await IdempotencyKeyModel.findOne({ key: 'unit-test-replay' }).lean().exec(); + expect(cacheEntry).toBeTruthy(); + expect(cacheEntry?.response.status).toBe(422); + expect(cacheEntry?.response.body).toEqual({ success: false, message: 'validation error' }); + + const second = await request(app) + .post('/test') + .set('Idempotency-Key', 'unit-test-replay') + .send({}); + + expect(second.status).toBe(first.status); + expect(second.body).toEqual(first.body); + }); + + it('should not cache 5xx responses', async () => { + const app = express(); + app.use(express.json()); + app.post('/test', idempotencyMiddleware(), (_req, res) => { + res.status(500).json({ success: false, message: 'Internal server error' }); + }); + + const first = await request(app) + .post('/test') + .set('Idempotency-Key', 'unit-test-5xx') + .send({}); + + expect(first.status).toBe(500); + + const cacheEntry = await IdempotencyKeyModel.findOne({ key: 'unit-test-5xx' }).lean().exec(); + expect(cacheEntry).toBeNull(); + }); + + it('should treat different keys as independent requests', async () => { + const callOrder: number[] = []; + const app = express(); + app.use(express.json()); + app.post('/test', idempotencyMiddleware(), (_req, res) => { + callOrder.push(1); + res.status(200).json({ success: true }); + }); + + await request(app) + .post('/test') + .set('Idempotency-Key', 'key-a') + .send({}); + + await request(app) + .post('/test') + .set('Idempotency-Key', 'key-b') + .send({}); + + expect(callOrder).toHaveLength(2); + + const count = await IdempotencyKeyModel.countDocuments({ + key: { $in: ['key-a', 'key-b'] }, + }); + expect(count).toBe(2); + }); +}); diff --git a/server/tests/unit/owner.repository.test.ts b/server/tests/unit/owner.repository.test.ts new file mode 100644 index 0000000000..a39ff4b205 --- /dev/null +++ b/server/tests/unit/owner.repository.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Types } from 'mongoose'; +import { getVenueAnalyticsData } from '../../src/modules/owner/owner.repository'; +import { BookingModel } from '../../src/modules/booking/models/booking.model'; +import { BookingStatus } from '../../src/constants/booking.constants'; + +vi.mock('../../src/modules/booking/models/booking.model', () => ({ + BookingModel: { + aggregate: vi.fn(), + }, +})); + +describe('Owner Repository - Analytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getVenueAnalyticsData', () => { + it('should query MongoDB aggregate with both CONFIRMED and COMPLETED statuses', async () => { + const mockVenueId = new Types.ObjectId().toString(); + const mockResult = [ + { year: '2026', month: '07', revenue: 15000, count: 3 }, + ]; + + vi.mocked(BookingModel.aggregate).mockResolvedValueOnce(mockResult); + + const result = await getVenueAnalyticsData(mockVenueId); + + expect(BookingModel.aggregate).toHaveBeenCalledTimes(1); + const pipeline = vi.mocked(BookingModel.aggregate).mock.calls[0][0] as { + $match: Record; + }[]; + + const matchStage = pipeline[0].$match; + expect(matchStage.venueId).toEqual(new Types.ObjectId(mockVenueId)); + expect(matchStage.status).toEqual({ + $in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED], + }); + + expect(result).toEqual(mockResult); + }); + }); +}); diff --git a/server/tests/unit/responseUtils.test.ts b/server/tests/unit/responseUtils.test.ts new file mode 100644 index 0000000000..b18457f38d --- /dev/null +++ b/server/tests/unit/responseUtils.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Response } from 'express'; +import { ResponseUtil } from '../../src/utils/responseUtils'; + +const mockResponse = () => { + const res = {} as Response; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +}; + +describe('ResponseUtil', () => { + it('should format success response correctly', () => { + const res = mockResponse(); + ResponseUtil.success(res, 'Success message', { key: 'value' }); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + message: 'Success message', + data: { key: 'value' }, + }); + }); + + it('should format created response with HTTP status 201', () => { + const res = mockResponse(); + ResponseUtil.created(res, 'Item created', { id: 1 }); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ + success: true, + message: 'Item created', + data: { id: 1 }, + }); + }); + + it('should format notFound response with HTTP status 404', () => { + const res = mockResponse(); + ResponseUtil.notFound(res, 'User not found'); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'User not found', + }); + }); + + it('should format badRequest response with HTTP status 400', () => { + const res = mockResponse(); + ResponseUtil.badRequest(res, 'Invalid input', 'Validation error'); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Invalid input', + error: 'Validation error', + }); + }); + + it('should format unauthorized response with HTTP status 401', () => { + const res = mockResponse(); + ResponseUtil.unauthorized(res, 'Token missing'); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Token missing', + }); + }); + + it('should format forbidden response with HTTP status 403', () => { + const res = mockResponse(); + ResponseUtil.forbidden(res, 'Access denied'); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Access denied', + }); + }); + + it('should format internalServerError response with HTTP status 500', () => { + const res = mockResponse(); + ResponseUtil.internalServerError(res, 'Database crash'); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Database crash', + }); + }); +}); diff --git a/server/tests/unit/validators.test.ts b/server/tests/unit/validators.test.ts new file mode 100644 index 0000000000..964e8c228c --- /dev/null +++ b/server/tests/unit/validators.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { registerSchema, loginSchema, forgotPasswordSchema } from '../../src/modules/auth/auth.validator'; + +describe('Auth Validators (Zod Schemas)', () => { + describe('registerSchema', () => { + it('should validate valid user registration payload', () => { + const validPayload = { + username: 'johndoe', + email: 'john@example.com', + password: 'Password@123', + }; + + const result = registerSchema.safeParse(validPayload); + expect(result.success).toBe(true); + }); + + it('should reject short username', () => { + const invalidPayload = { + username: 'jo', + email: 'john@example.com', + password: 'Password@123', + }; + + const result = registerSchema.safeParse(invalidPayload); + expect(result.success).toBe(false); + }); + + it('should reject weak password without special character or uppercase', () => { + const invalidPayload = { + username: 'johndoe', + email: 'john@example.com', + password: 'password123', + }; + + const result = registerSchema.safeParse(invalidPayload); + expect(result.success).toBe(false); + }); + }); + + describe('loginSchema', () => { + it('should validate login with email', () => { + const result = loginSchema.safeParse({ + email: 'john@example.com', + password: 'Password@123', + }); + expect(result.success).toBe(true); + }); + + it('should validate login with username', () => { + const result = loginSchema.safeParse({ + username: 'johndoe', + password: 'Password@123', + }); + expect(result.success).toBe(true); + }); + + it('should reject when neither email nor username is provided', () => { + const result = loginSchema.safeParse({ + password: 'Password@123', + }); + expect(result.success).toBe(false); + }); + }); + + describe('forgotPasswordSchema', () => { + it('should validate valid email', () => { + const result = forgotPasswordSchema.safeParse({ email: 'user@example.com' }); + expect(result.success).toBe(true); + }); + + it('should reject invalid email format', () => { + const result = forgotPasswordSchema.safeParse({ email: 'invalid-email' }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000000..044b705dba --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "useUnknownInCatchVariables": true + }, + "include": ["src/**/*", "scripts/tools/cleanupBans.ts"], + "exclude": ["node_modules", "dist", "scripts"] +} diff --git a/server/tsconfig.tsbuildinfo b/server/tsconfig.tsbuildinfo new file mode 100644 index 0000000000..9711ecc008 --- /dev/null +++ b/server/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/router.ts","./src/server.ts","./src/configs/cors.config.ts","./src/configs/database.config.ts","./src/constants/auth.constants.ts","./src/constants/common.ts","./src/constants/email.constants.ts","./src/constants/env.ts","./src/constants/permissions.ts","./src/constants/venue.constants.ts","./src/middlewares/auth.middleware.ts","./src/middlewares/pagination.middleware.ts","./src/middlewares/rbac.middleware.ts","./src/middlewares/validation.middleware.ts","./src/models/email-task.model.ts","./src/models/permission.model.ts","./src/models/role-permission.model.ts","./src/models/role.model.ts","./src/models/user-role.model.ts","./src/modules/auth/auth.controller.ts","./src/modules/auth/auth.repository.ts","./src/modules/auth/auth.routes.ts","./src/modules/auth/auth.service.ts","./src/modules/auth/auth.validator.ts","./src/modules/auth/models/password-reset-request.model.ts","./src/modules/auth/models/password-reset-token.model.ts","./src/modules/auth/models/refresh-token.model.ts","./src/modules/auth/models/session.model.ts","./src/modules/availability/availability.controller.ts","./src/modules/availability/availability.repository.ts","./src/modules/availability/availability.router.ts","./src/modules/availability/availability.validator.ts","./src/modules/availability/availability.workflow.ts","./src/modules/booking/booking.controller.ts","./src/modules/booking/booking.model.ts","./src/modules/booking/booking.repository.ts","./src/modules/booking/booking.routes.ts","./src/modules/booking/booking.types.ts","./src/modules/booking/booking.validator.ts","./src/modules/booking/failedbooking.model.ts","./src/modules/booking/lock.model.ts","./src/modules/booking/lock.types.ts","./src/modules/booking/processedwebhook.model.ts","./src/modules/rbac/rbac.controller.ts","./src/modules/rbac/rbac.routes.ts","./src/modules/rbac/rbac.service.ts","./src/modules/user/user.controller.ts","./src/modules/user/user.models.ts","./src/modules/user/user.repository.ts","./src/modules/user/user.routes.ts","./src/modules/user/user.service.ts","./src/modules/venue/venue.controller.ts","./src/modules/venue/venue.model.ts","./src/modules/venue/venue.ownership.ts","./src/modules/venue/venue.repository.ts","./src/modules/venue/venue.routes.ts","./src/modules/venue/venue.service.ts","./src/modules/venue/venue.types.ts","./src/modules/venue/venue.validator.ts","./src/modules/venue/venue.workflow.ts","./src/modules/venue/venuedraft.model.ts","./src/modules/webhook/webhook.controller.ts","./src/modules/webhook/webhook.router.ts","./src/services/email.service.ts","./src/services/razorpay.service.ts","./src/services/roles.service.ts","./src/services/cache/permission-cache.service.ts","./src/types/express.ts","./src/types/pagination.types.ts","./src/utils/cacheutils.ts","./src/utils/dbutils.ts","./src/utils/errors.ts","./src/utils/logger.ts","./src/utils/paginationutils.ts","./src/utils/responseutils.ts","./src/utils/shutdownutils.ts","./src/utils/timeutils.ts","./src/utils/tokenutils.ts","./src/utils/winston/logger.ts","./src/utils/winston/requestlogger.ts","./src/workers/email.worker.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 0000000000..35a4850a3b --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + globalSetup: ['./tests/globalSetup.ts'], + setupFiles: ['./tests/setup.ts'], + testTimeout: 60000, + hookTimeout: 120000, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'dist/', 'tests/'], + }, + }, +});