From ba5d6e070769d1ab38df7f7b36cd18c291adc318 Mon Sep 17 00:00:00 2001 From: Avom Brice Date: Tue, 20 Jan 2026 14:59:19 +0100 Subject: [PATCH 1/3] ci: add security audit to workflow - Add separate security audit job to check for dependency vulnerabilities - Runs pnpm audit with moderate severity level - Continues on error to not block CI pipeline --- .github/dependabot.yml | 43 ----- .github/pull_request_template.md | 18 +- .github/workflows/ci.yml | 198 ++++++++++---------- .github/workflows/database.yml | 69 ------- .github/workflows/dependabot-auto-merge.yml | 57 ------ .github/workflows/release.yml | 9 - 6 files changed, 103 insertions(+), 291 deletions(-) delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/database.yml delete mode 100644 .github/workflows/dependabot-auto-merge.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 3df642b..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,43 +0,0 @@ -# Dependabot Configuration -# -# This file configures Dependabot to automatically check for and create PRs -# for dependency updates. It helps keep your project dependencies secure and up-to-date. -# -# Dependabot will: -# - Check for updates daily -# - Create PRs for security updates immediately -# - Group related updates together -# - Use the same package manager (pnpm) as your project - -version: 2 -updates: - # Enable version updates for npm/pnpm packages - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "daily" # Check for updates daily - time: "04:00" # At 4 AM UTC - open-pull-requests-limit: 10 # Maximum number of open PRs - reviewers: - - "frckbrice" # Add your GitHub username here - labels: - - "dependencies" - - "automated" - # Group updates by dependency type - groups: - production-dependencies: - patterns: - - "*" - update-types: - - "minor" - - "patch" - # Ignore specific packages if needed - ignore: - # Example: Ignore major version updates for a specific package - # - dependency-name: "package-name" - # update-types: ["version-update:semver-major"] - - # Commit message preferences - commit-message: - prefix: "chore" - include: "scope" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c7edeeb..ee08755 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,15 +5,15 @@ ## Type of Change -- [ ] πŸ› Bug fix (non-breaking change which fixes an issue) -- [ ] ✨ New feature (non-breaking change which adds functionality) -- [ ] πŸ’₯ Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] πŸ“š Documentation update -- [ ] 🎨 Code style/formatting changes -- [ ] ♻️ Code refactoring -- [ ] ⚑ Performance improvement -- [ ] βœ… Test updates -- [ ] πŸ”§ Build/config changes +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Code style/formatting changes +- [ ] Code refactoring +- [ ] Performance improvement +- [ ] Test updates +- [ ] Build/config changes ## Related Issues diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3f2efd..0ca8083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ # Continuous Integration Workflow -# +# # This workflow runs on every push and pull request to ensure code quality. # It performs the following checks: # 1. Type checking (TypeScript compilation without emitting files) @@ -13,111 +13,101 @@ name: CI # Trigger the workflow on push and pull requests on: - push: - branches: - - main - - develop - - 'feature/**' - - 'fix/**' - - 'hotfix/**' - - 'release/**' - pull_request: - branches: - - main - - develop + push: + branches: + - main + - develop + - "feature/**" + - "fix/**" + - "hotfix/**" + - "release/**" + pull_request: + branches: + - main + - develop # Allow only one concurrent workflow per branch concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - # Main CI job that runs all checks - ci: - name: CI Checks - runs-on: ubuntu-latest - - # Strategy to test against multiple Node.js versions - strategy: - matrix: - node-version: [20.x, 22.x] - fail-fast: false - - steps: - # Checkout the repository code - - name: Checkout code - uses: actions/checkout@v4 - - # Setup pnpm package manager - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - # Setup Node.js with the version from matrix - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'pnpm' - - # Install dependencies - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # Run TypeScript type checking - - name: Type check - run: pnpm check - - # Run ESLint to check code quality - - name: Lint - run: pnpm lint - continue-on-error: false - - # Run tests with Jest - - name: Test - run: pnpm test - env: - NODE_ENV: test - - # Build the TypeScript project - - name: Build - run: pnpm build - - # Upload test coverage reports (optional, for coverage visualization) - - name: Upload coverage reports - if: matrix.node-version == '20.x' - uses: codecov/codecov-action@v4 - with: - file: ./coverage/lcov.info - flags: unittests - name: codecov-umbrella - fail_ci_if_error: false - - # Separate job for security checks (dependencies vulnerability scanning) - security: - name: Security Audit - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # Run pnpm audit to check for known vulnerabilities - - name: Run security audit - run: pnpm audit --audit-level=moderate - continue-on-error: true + # Main CI job that runs all checks + ci: + name: CI Checks + runs-on: ubuntu-latest + + # Strategy to test against multiple Node.js versions + strategy: + matrix: + node-version: [20.x, 22.x] + fail-fast: false + + steps: + # Checkout the repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Setup pnpm package manager + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + # Setup Node.js with the version from matrix + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "pnpm" + + # Install dependencies + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Run TypeScript type checking + - name: Type check + run: pnpm check + + # Run ESLint to check code quality + - name: Lint + run: pnpm lint + continue-on-error: false + + # Run tests with Jest + - name: Test + run: pnpm test + env: + NODE_ENV: test + + # Build the TypeScript project + - name: Build + run: pnpm build + + # Security audit job to check for dependency vulnerabilities + security: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.x" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Run pnpm audit to check for known vulnerabilities + - name: Run security audit + run: pnpm audit --audit-level=moderate + continue-on-error: true diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml deleted file mode 100644 index 0e07177..0000000 --- a/.github/workflows/database.yml +++ /dev/null @@ -1,69 +0,0 @@ -# Database Migration Workflow -# -# This workflow handles database migrations and schema checks. -# It can be used to: -# - Validate database schema changes -# - Run migrations in a test environment -# - Generate migration files -# -# Note: This workflow requires database credentials to be set as GitHub secrets. -# Required secrets: -# - DATABASE_URL: PostgreSQL connection string - -name: Database - -# Trigger manually or on specific file changes -on: - workflow_dispatch: # Allows manual triggering - push: - branches: - - main - - develop - paths: - - 'config/database/**' - - 'drizzle/**' - - 'drizzle.config.ts' - -jobs: - # Validate database schema - validate-schema: - name: Validate Schema - runs-on: ubuntu-latest - - # Skip if database URL is not available - if: ${{ secrets.DATABASE_URL != '' }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # Generate migration files to check for schema changes - - name: Generate migrations - run: pnpm db:generate - env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - - # Check if there are uncommitted migration files - - name: Check for uncommitted migrations - run: | - if [ -n "$(git status --porcelain drizzle/)" ]; then - echo "⚠️ Uncommitted migration files detected!" - git status - exit 1 - else - echo "βœ… All migrations are committed" - fi diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index e2bab6f..0000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,57 +0,0 @@ -# Dependabot Auto-Merge Workflow -# -# This workflow automatically merges Dependabot PRs that pass all CI checks. -# It helps keep dependencies up-to-date with minimal manual intervention. -# -# Requirements: -# - Dependabot must be enabled in repository settings -# - Branch protection rules should allow auto-merge - -name: Dependabot Auto-Merge - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - # Auto-merge Dependabot PRs that pass CI - auto-merge: - name: Auto-merge Dependabot PRs - runs-on: ubuntu-latest - - # Only run for Dependabot PRs - if: github.actor == 'dependabot[bot]' - - steps: - - name: Wait for CI to complete - uses: lewagon/wait-on-check-action@v1.3.4 - with: - ref: ${{ github.event.pull_request.head.sha }} - check-regexp: '^CI' - repo-token: ${{ secrets.GITHUB_TOKEN }} - wait-interval: 10 - allowed-conclusions: success,neutral - - # Approve the PR - - name: Approve PR - uses: actions/github-script@v7 - with: - script: | - github.rest.pulls.createReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - event: 'APPROVE' - }) - - # Enable auto-merge - - name: Enable auto-merge - uses: actions/github-script@v7 - with: - script: | - github.rest.pulls.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - merge_method: 'squash' - }) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3028ba5..4635374 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,12 +69,3 @@ jobs: name: dist path: dist/ retention-days: 30 - - # Optional: Upload to GitHub Releases - - name: Upload to release - if: github.event_name == 'release' - uses: softprops/action-gh-release@v2 - with: - files: dist/** - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From a9597d35ab344104fb8145b01f74ec40e279d0a3 Mon Sep 17 00:00:00 2001 From: Avom Brice Date: Sat, 7 Feb 2026 04:19:06 +0100 Subject: [PATCH 2/3] fix: sync lockfile and schema-validation updates for Render deploy --- .github/workflows/ci.yml | 1 + README.md | 483 +-- config/cors/allowed-origins.ts | 6 + config/cors/cors-options.ts | 20 +- config/database/db.ts | 21 +- config/database/seed.ts | 14 +- config/database/storage.ts | 38 +- config/swagger.ts | 2013 ++++++++++ index.ts | 52 +- jest.config.js | 5 +- middlewares/errors/error-handler.ts | 21 - middlewares/logger.ts | 31 - package.json | 8 +- pnpm-lock.yaml | 3279 +++++++++-------- pnpm-workspace.yaml | 3 + render.yaml | 16 + server/routes.ts | 1636 -------- src/config/env.ts | 2 + src/controllers/admin.controller.ts | 76 + src/controllers/auth.controller.ts | 59 +- src/controllers/contact.controller.ts | 69 + src/controllers/events.controller.ts | 80 + src/controllers/libraries.controller.ts | 78 + src/controllers/maintenance.controller.ts | 88 + src/controllers/media.controller.ts | 113 + src/controllers/settings.controller.ts | 44 + src/controllers/stories.controller.ts | 138 + src/controllers/superadmin.controller.ts | 132 + src/middlewares/auth.ts | 33 +- src/middlewares/error-handler.ts | 88 +- src/middlewares/logger.ts | 15 +- .../middlewares}/rate-limiters.ts | 48 +- src/middlewares/validation.ts | 29 +- src/routes/admin.routes.ts | 35 +- src/routes/auth.routes.ts | 21 +- src/routes/contact.routes.ts | 81 +- src/routes/events.routes.ts | 82 +- src/routes/index.ts | 60 +- src/routes/libraries.routes.ts | 18 +- src/routes/maintenance.routes.ts | 18 +- src/routes/media.routes.ts | 44 +- src/routes/settings.routes.ts | 16 +- src/routes/shared.ts | 58 +- src/routes/stories.routes.ts | 142 +- src/routes/superadmin.routes.ts | 23 +- src/services/admin.service.ts | 216 ++ src/services/auth.service.ts | 73 + src/services/contact.service.ts | 113 + .../services}/drizzle-services.ts | 73 +- {services => src/services}/email-service.ts | 27 + src/services/events.service.ts | 129 + src/services/libraries.service.ts | 147 + src/services/maintenance.service.ts | 234 ++ src/services/media.service.ts | 148 + src/services/settings.service.ts | 89 + src/services/stories.service.ts | 169 + src/services/superadmin.service.ts | 223 ++ src/utils/api-response.ts | 146 + src/utils/errors.ts | 2 +- src/utils/validations.ts | 28 + src/validations/story.schemas.ts | 18 +- tests/README.md | 14 +- tests/{utils => helpers}/mocks.ts | 11 +- tests/setup.ts | 7 +- .../unit/controllers/admin.controller.test.ts | 134 + .../unit/controllers/auth.controller.test.ts | 70 +- .../controllers/contact.controller.test.ts | 116 + .../controllers/events.controller.test.ts | 131 + .../controllers/libraries.controller.test.ts | 122 + .../maintenance.controller.test.ts | 188 + .../unit/controllers/media.controller.test.ts | 157 + .../controllers/settings.controller.test.ts | 65 + .../controllers/stories.controller.test.ts | 150 + .../controllers/superadmin.controller.test.ts | 205 ++ tests/unit/middlewares/auth.test.ts | 114 +- tests/unit/middlewares/validation.test.ts | 106 +- tests/unit/routes/routes.test.ts | 230 ++ tests/unit/services/admin.service.test.ts | 85 + tests/unit/services/auth.service.test.ts | 122 + tests/unit/services/contact.service.test.ts | 103 + tests/unit/services/events.service.test.ts | 126 + tests/unit/services/libraries.service.test.ts | 106 + .../unit/services/maintenance.service.test.ts | 123 + tests/unit/services/media.service.test.ts | 123 + tests/unit/services/settings.service.test.ts | 45 + tests/unit/services/stories.service.test.ts | 173 + .../unit/services/superadmin.service.test.ts | 199 + tsconfig.json | 2 +- utils/validations.ts | 17 - 89 files changed, 10345 insertions(+), 3871 deletions(-) create mode 100644 config/swagger.ts delete mode 100644 middlewares/errors/error-handler.ts delete mode 100644 middlewares/logger.ts create mode 100644 pnpm-workspace.yaml create mode 100644 render.yaml delete mode 100644 server/routes.ts create mode 100644 src/controllers/admin.controller.ts create mode 100644 src/controllers/contact.controller.ts create mode 100644 src/controllers/events.controller.ts create mode 100644 src/controllers/libraries.controller.ts create mode 100644 src/controllers/maintenance.controller.ts create mode 100644 src/controllers/media.controller.ts create mode 100644 src/controllers/settings.controller.ts create mode 100644 src/controllers/stories.controller.ts create mode 100644 src/controllers/superadmin.controller.ts rename {middlewares => src/middlewares}/rate-limiters.ts (74%) create mode 100644 src/services/admin.service.ts create mode 100644 src/services/auth.service.ts create mode 100644 src/services/contact.service.ts rename {services => src/services}/drizzle-services.ts (91%) rename {services => src/services}/email-service.ts (86%) create mode 100644 src/services/events.service.ts create mode 100644 src/services/libraries.service.ts create mode 100644 src/services/maintenance.service.ts create mode 100644 src/services/media.service.ts create mode 100644 src/services/settings.service.ts create mode 100644 src/services/stories.service.ts create mode 100644 src/services/superadmin.service.ts create mode 100644 src/utils/api-response.ts create mode 100644 src/utils/validations.ts rename tests/{utils => helpers}/mocks.ts (84%) create mode 100644 tests/unit/controllers/admin.controller.test.ts create mode 100644 tests/unit/controllers/contact.controller.test.ts create mode 100644 tests/unit/controllers/events.controller.test.ts create mode 100644 tests/unit/controllers/libraries.controller.test.ts create mode 100644 tests/unit/controllers/maintenance.controller.test.ts create mode 100644 tests/unit/controllers/media.controller.test.ts create mode 100644 tests/unit/controllers/settings.controller.test.ts create mode 100644 tests/unit/controllers/stories.controller.test.ts create mode 100644 tests/unit/controllers/superadmin.controller.test.ts create mode 100644 tests/unit/routes/routes.test.ts create mode 100644 tests/unit/services/admin.service.test.ts create mode 100644 tests/unit/services/auth.service.test.ts create mode 100644 tests/unit/services/contact.service.test.ts create mode 100644 tests/unit/services/events.service.test.ts create mode 100644 tests/unit/services/libraries.service.test.ts create mode 100644 tests/unit/services/maintenance.service.test.ts create mode 100644 tests/unit/services/media.service.test.ts create mode 100644 tests/unit/services/settings.service.test.ts create mode 100644 tests/unit/services/stories.service.test.ts create mode 100644 tests/unit/services/superadmin.service.test.ts delete mode 100644 utils/validations.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ca8083..4378f9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ # 2. Linting (ESLint) # 3. Testing (Jest) # 4. Building (TypeScript compilation) + # # The workflow uses pnpm as the package manager and supports multiple Node.js versions. diff --git a/README.md b/README.md index 1d35a45..27dfc2a 100644 --- a/README.md +++ b/README.md @@ -1,293 +1,300 @@ # Library Management REST API -A comprehensive RESTful API backend for managing library content, resources, and operations. This system provides a robust platform for libraries to manage their collections, stories, media items, events, and user interactions through a well-structured API. +[![CI](https://github.com/frckbrice/library-management-REST-API/actions/workflows/ci.yml/badge.svg)](https://github.com/frckbrice/library-management-REST-API/actions/workflows/ci.yml) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue.svg)](https://www.typescriptlang.org/) +[![Node.js](https://img.shields.io/badge/Node.js-20%2B%20%7C%2022-green.svg)](https://nodejs.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +A production-ready RESTful API for library content managementβ€”collections, stories, media, events, and user operations. Built with modern TypeScript, Express.js, and PostgreSQL. Designed for scalability, maintainability, and secure multi-tenant operations. + +**Repository:** [github.com/frckbrice/library-management-REST-API](https://github.com/frckbrice/library-management-REST-API) + +--- ## Overview -This is a production-ready Node.js backend application built with Express.js, postgresql and TypeScript. It implements a complete content management system for libraries with role-based access control, content moderation, analytics tracking, and email communication capabilities. +This API powers a full content management system for libraries with role-based access control, content moderation, analytics, and email workflows. It follows RESTful design principles, uses type-safe validation, and ships with interactive API documentation. + +### Highlights + +- **Type-safe stack** β€” TypeScript, Zod schemas, Drizzle ORM with inferred types +- **CI/CD pipeline** β€” GitHub Actions (type check, lint, test, build, security audit) +- **Cloud-ready** β€” Neon (serverless Postgres), Cloudinary (media), Render deployment +- **Security-first** β€” Helmet, CORS, rate limiting, bcrypt, session-based auth + +--- ## Technology Stack -- **Runtime**: Node.js with Express.js -- **Language**: TypeScript -- **Database**: PostgreSQL with Drizzle ORM -- **Authentication**: Session-based authentication with bcrypt password hashing -- **File Storage**: Cloudinary for image and media uploads -- **Email Service**: Nodemailer for automated email responses -- **Validation**: Zod for schema validation -- **Rate Limiting**: Express-rate-limit for API protection -- **Session Management**: Express-session with PostgreSQL or memory store +| Layer | Technology | +|-------|------------| +| **Runtime** | Node.js 20+ / 22 | +| **Framework** | Express.js | +| **Language** | TypeScript 5.6 (strict mode) | +| **Database** | PostgreSQL (Neon serverless, `pg`) | +| **ORM** | Drizzle ORM | +| **Validation** | Zod | +| **Auth** | Session-based, bcrypt, express-session | +| **Media** | Cloudinary (multer + multer-storage-cloudinary) | +| **Email** | Nodemailer | +| **API Docs** | Swagger UI (OpenAPI 3) | +| **Security** | Helmet, CORS, express-rate-limit | +| **Tests** | Jest, ts-jest | +| **Package Manager** | pnpm | + +--- ## Features ### Authentication & Authorization -- Session-based user authentication -- Role-based access control (library_admin, super_admin) -- Secure password hashing with bcrypt -- Session management with configurable stores + +- Session-based authentication with secure cookie handling +- Role-based access control: `library_admin`, `super_admin` +- Bcrypt password hashing +- Configurable session stores (PostgreSQL, in-memory for dev) ### Content Management -- **Libraries**: Complete library profile management with location, description, images, and metadata -- **Stories**: Rich text content management with featured images, tags, and approval workflow -- **Timelines**: Interactive timeline creation associated with stories -- **Media Items**: Image, video, and audio management organized into galleries -- **Events**: Library event management with dates, locations, and images - -### Moderation System -- Content approval workflow for stories and media items -- Super admin moderation capabilities -- Featured content management -- Publication status control + +| Domain | Capabilities | +|--------|--------------| +| **Libraries** | CRUD, location, metadata, images, approval workflow | +| **Stories** | Rich text, featured images, tags, publish/approve lifecycle | +| **Timelines** | Timeline points linked to stories | +| **Media Items** | Images, video, audio; galleries; tags; approval | +| **Events** | Dates, locations, images; full CRUD | + +### Moderation & Admin + +- Content approval flows for stories and media +- Super admin moderation and featured content management +- Admin dashboard: stats, analytics, activity logs ### Public API -- Public endpoints for browsing libraries, stories, events, and media -- Advanced search functionality -- Filtering by tags, library, published status, and more -- Pagination support - -### Communication -- Contact message system for visitor inquiries -- Automated email response system with customizable templates -- Email notification system - -### Analytics & Reporting -- View tracking and analytics collection -- Admin dashboard with comprehensive statistics -- Activity logging and monitoring - -### Security & Performance -- Rate limiting on all endpoints with different tiers -- CORS configuration for cross-origin requests -- Input validation and sanitization -- Error handling middleware -- Request logging + +- Public endpoints for libraries, stories, events, media +- Search, filters (tags, library, status), pagination +- Contact form + automated email replies + +### Developer Experience + +- Type-safe environment config (Zod) with startup validation +- Centralized error handling and structured logging (Winston) +- Consistent API response format +- OpenAPI documentation at `/api-docs` + +--- ## Project Structure ``` β”œβ”€β”€ config/ -β”‚ β”œβ”€β”€ bucket-storage/ # Cloudinary configuration -β”‚ β”œβ”€β”€ cors/ # CORS configuration -β”‚ └── database/ # Database schema, migrations, and seed data -β”œβ”€β”€ middlewares/ -β”‚ β”œβ”€β”€ errors/ # Error handling middleware -β”‚ β”œβ”€β”€ logger.ts # Request logging -β”‚ └── rate-limiters.ts # Rate limiting configuration -β”œβ”€β”€ server/ -β”‚ └── routes.ts # API route definitions -β”œβ”€β”€ services/ -β”‚ β”œβ”€β”€ drizzle-services.ts # Database service layer -β”‚ └── email-service.ts # Email service implementation -└── utils/ - └── validations.ts # Validation utilities +β”‚ β”œβ”€β”€ bucket-storage/ # Cloudinary setup +β”‚ β”œβ”€β”€ cors/ # CORS config +β”‚ β”œβ”€β”€ database/ # Schema, migrations, seed, storage +β”‚ └── swagger.ts # OpenAPI spec +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ config/ # Env validation (Zod) +β”‚ β”œβ”€β”€ controllers/ # Request handlers +β”‚ β”œβ”€β”€ middlewares/ # Auth, validation, error-handler, logger, rate-limiters +β”‚ β”œβ”€β”€ routes/ # API route definitions +β”‚ β”œβ”€β”€ services/ # Business logic (drizzle, email) +β”‚ β”œβ”€β”€ types/ # TypeScript declarations +β”‚ β”œβ”€β”€ utils/ # Errors, validations, api-response +β”‚ └── validations/ # Zod request schemas +β”œβ”€β”€ tests/ +β”‚ β”œβ”€β”€ helpers/ # Mocks (Request, Response, session) +β”‚ └── unit/ # Controllers, services, middlewares, routes, utils +β”œβ”€β”€ .github/workflows/ # CI pipeline +β”œβ”€β”€ index.ts # App entry point +└── render.yaml # Render deployment config ``` -## Installation +--- + +## Quick Start ### Prerequisites -- Node.js (v18 or higher) -- PostgreSQL database -- pnpm package manager (or npm/yarn) -- Cloudinary account (for file uploads) -- Email service credentials (Gmail or SMTP) -### Setup +- Node.js 20+ or 22 +- PostgreSQL (or [Neon](https://neon.tech)) +- pnpm +- Cloudinary account (for uploads) +- SMTP/Gmail credentials (for email) -1. Clone the repository: -```bash -git clone -cd museumCall_backend -``` +### Installation -2. Install dependencies: ```bash +# Clone the repository +git clone git@github.com:frckbrice/library-management-REST-API.git +cd library-management-REST-API + +# Install dependencies pnpm install -``` -3. Configure environment variables: -Create a `.env` file in the root directory with the following variables: +# Create .env (see Environment Variables below) +cp .env.example .env -```env -# Database -DATABASE_URL=postgresql://user:password@localhost:5432/library_db -DATAAPI_URL=your_neon_dataapi_url_if_using_neon +# Database setup +pnpm db:push +pnpm db:seed -# Session -SESSION_SECRET=your_session_secret_key +# Start development server +pnpm dev +``` -# Cloudinary -CLOUDINARY_CLOUD_NAME=your_cloud_name -CLOUDINARY_API_KEY=your_api_key -CLOUDINARY_API_SECRET=your_api_secret +Server runs at **http://localhost:5500**. +Interactive API docs: **http://localhost:5500/api-docs** -# Email (Gmail) -GMAIL_USER=your_email@gmail.com -GMAIL_PASS=your_app_password +### Environment Variables -# Server +```env +# Required +SESSION_SECRET=your_session_secret_min_32_chars + +# Database (one of) +DATABASE_URL=postgresql://user:pass@host:5432/db +DATAAPI_URL=https://your-neon-project.neon.tech/sql # Neon serverless + +# Optional PORT=5500 NODE_ENV=development - -# CORS ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 + +# Cloudinary (for uploads) +CLOUDINARY_CLOUD_NAME=... +CLOUDINARY_API_KEY=... +CLOUDINARY_API_SECRET=... + +# Email (Nodemailer) +GMAIL_USER=... +GMAIL_PASS=... +GMAIL_APP_SUPPORT=... ``` -4. Set up the database: -```bash -# Generate database migrations -pnpm db:generate +--- -# Push schema to database -pnpm db:push +## API Endpoints (Summary) -# Seed the database with sample data -pnpm db:seed -``` +| Group | Endpoints | +|-------|-----------| +| **Auth** | `POST /api/v1/auth/login`, `GET /session`, `POST /logout` | +| **Libraries** | `GET/POST/PATCH /api/v1/libraries` | +| **Stories** | `GET /api/v1/stories`, `POST/PATCH /api/v1/admin/stories` | +| **Timelines** | `GET/POST /api/v1/admin/stories/:id/timelines` | +| **Media** | `GET/POST/PATCH /api/v1/media-items` | +| **Events** | `GET/POST/PATCH/DELETE /api/v1/events` | +| **Contact** | `GET/POST /api/v1/contact-messages`, `POST /:id/reply` | +| **Super Admin** | Moderation, users, libraries | +| **Admin** | Dashboard stats, analytics, activity | +| **Health** | `GET /api/v1/health` | + +Full documentation is available at `/api-docs` when the server is running. + +--- + +## Scripts + +| Command | Description | +|---------|-------------| +| `pnpm dev` | Start dev server (nodemon + ts-node) | +| `pnpm build` | Compile TypeScript | +| `pnpm start` | Run production build | +| `pnpm check` | Type check (no emit) | +| `pnpm lint` | ESLint | +| `pnpm test` | Jest tests | +| `pnpm test:watch` | Jest watch mode | +| `pnpm db:push` | Push schema to database | +| `pnpm db:generate` | Generate migrations | +| `pnpm db:seed` | Seed database | +| `pnpm db:reset` | Reset database | + +--- + +## Testing + +- **Framework:** Jest + ts-jest +- **Scope:** Unit tests for controllers, services, middlewares, routes, utils +- **Helpers:** Shared mocks in `tests/helpers/mocks.ts` -5. Start the development server: ```bash -pnpm dev +pnpm test +pnpm test -- --coverage +pnpm test:watch ``` -The server will start on `http://localhost:5500` (or the port specified in your `.env` file). - -## API Endpoints - -### Authentication -- `POST /api/v1/auth/login` - User login -- `GET /api/v1/auth/session` - Get current session -- `POST /api/v1/auth/logout` - User logout - -### Libraries -- `GET /api/v1/libraries` - Get all libraries (public) -- `GET /api/v1/libraries/:id` - Get library by ID (public) -- `POST /api/v1/libraries` - Create library (super_admin only) -- `PATCH /api/v1/libraries/:id` - Update library (admin) - -### Stories -- `GET /api/v1/stories` - Get all stories (public, supports filtering) -- `GET /api/v1/stories/:id` - Get story by ID (public) -- `GET /api/v1/stories/tags` - Get all story tags (public) -- `POST /api/v1/admin/stories` - Create story (authenticated) -- `PATCH /api/v1/admin/stories/:id` - Update story (authenticated) -- `GET /api/v1/admin/stories/:id` - Get story for editing (authenticated) - -### Timelines -- `GET /api/v1/admin/stories/:id/timelines` - Get timelines for a story -- `POST /api/v1/admin/stories/:id/timelines` - Create timeline for a story - -### Media Items -- `GET /api/v1/media-items` - Get all media items (public, supports filtering) -- `GET /api/v1/media-items/:id` - Get media item by ID (public) -- `POST /api/v1/media-items` - Upload media item (authenticated) -- `PATCH /api/v1/media-items/:id` - Update media item (authenticated) - -### Events -- `GET /api/v1/events` - Get all events (public) -- `POST /api/v1/events` - Create event (authenticated) -- `PATCH /api/v1/events/:id` - Update event (authenticated) -- `DELETE /api/v1/events/:id` - Delete event (authenticated) - -### Contact Messages -- `GET /api/v1/contact-messages` - Get contact messages (authenticated) -- `POST /api/v1/contact-messages` - Submit contact message (public) -- `PATCH /api/v1/contact-messages/:id` - Update message status -- `POST /api/v1/contact-messages/:id/reply` - Send email response - -### Super Admin -- `GET /api/v1/superadmin/moderation/stories` - Get stories pending approval -- `GET /api/v1/superadmin/moderation/media` - Get media pending approval -- `PATCH /api/v1/superadmin/stories/:id/approve` - Approve story -- `PATCH /api/v1/superadmin/stories/:id/reject` - Reject story -- `GET /api/v1/superadmin/libraries` - Get all libraries -- `GET /api/v1/superadmin/users` - Get all users -- `POST /api/v1/superadmin/users` - Create user -- `PATCH /api/v1/superadmin/users/:id` - Update user - -### Admin Dashboard -- `GET /api/v1/admin/dashboard/stats` - Get dashboard statistics -- `GET /api/v1/admin/dashboard/analytics` - Get analytics data -- `GET /api/v1/admin/dashboard/activity` - Get recent activity - -### Utilities -- `GET /api/v1/health` - Health check endpoint -- `GET /api/v1/admin/galleries` - Get all galleries -- `GET /api/v1/admin/media/tags` - Get all media tags - -## Database Schema - -The application uses PostgreSQL with the following main entities: - -- **users**: User accounts with role-based access -- **libraries**: Library profiles and information -- **stories**: Content stories with rich text -- **media_items**: Media files (images, videos, audio) -- **timelines**: Timeline data associated with stories -- **events**: Library events and programs -- **contact_messages**: Visitor contact submissions -- **analytics**: View and engagement tracking -- **email_templates**: Customizable email templates -- **message_responses**: Email response tracking - -## Development - -### Available Scripts - -- `pnpm dev` - Start development server with hot reload -- `pnpm build` - Build TypeScript to JavaScript -- `pnpm start` - Start production server -- `pnpm check` - Type check without emitting files -- `pnpm lint` - Run ESLint -- `pnpm test` - Run tests -- `pnpm db:push` - Push schema changes to database -- `pnpm db:generate` - Generate database migrations -- `pnpm db:seed` - Seed database with sample data -- `pnpm db:reset` - Reset database - -### Code Quality - -The project follows TypeScript best practices with: -- Strict type checking -- Consistent code formatting -- Comprehensive error handling -- Input validation using Zod schemas -- RESTful API design principles - -## Security Features - -- Password hashing with bcrypt -- Session-based authentication -- Rate limiting on all endpoints -- CORS protection -- Input validation and sanitization -- SQL injection prevention through ORM -- Secure file upload handling - -## Rate Limiting - -The API implements different rate limiting tiers: -- **Public endpoints**: Standard rate limits -- **Admin endpoints**: Stricter rate limits -- **Search endpoints**: Special rate limits for search operations -- **Contact endpoints**: Dedicated rate limits for contact forms -- **Email endpoints**: Rate limits for email sending - -## Error Handling - -The application includes comprehensive error handling: -- Centralized error handler middleware -- Consistent error response format -- Detailed error logging -- User-friendly error messages +See [tests/README.md](tests/README.md) for details. + +--- + +## Deployment (Render) + +A [Render Blueprint](render.yaml) is included for one-click deployment: + +- **Build:** `pnpm install && pnpm run build` +- **Start:** `pnpm start` +- **Health check:** `GET /api/v1/health` + +Configure in Render Dashboard: + +- `NODE_ENV=production` +- `DATABASE_URL` or `DATAAPI_URL` +- `SESSION_SECRET` +- `ALLOWED_ORIGINS` +- Cloudinary and email vars as needed + +--- + +## CI/CD (GitHub Actions) + +The [CI workflow](.github/workflows/ci.yml) runs on push/PR to `main`, `develop`, and feature branches: + +1. Type check (`pnpm check`) +2. Lint (`pnpm lint`) +3. Tests (`pnpm test`) +4. Build (`pnpm build`) +5. Security audit (`pnpm audit`) + +Matrix: Node.js 20.x and 22.x. + +--- + +## Security + +- **Helmet** β€” Security headers +- **CORS** β€” Configurable allowed origins +- **Rate limiting** β€” Tiered limits per route type +- **Input validation** β€” Zod schemas on all inputs +- **Password hashing** β€” bcrypt +- **ORM** β€” Parameterized queries (Drizzle) +- **Session** β€” Secure cookies, server-side storage + +--- + +## Database Schema (Main Entities) + +- `users` β€” Auth, roles, library association +- `libraries` β€” Profiles, metadata, location +- `stories` β€” Rich text, tags, publish/approval +- `media_items` β€” Images, video, audio; galleries +- `timelines` β€” Timeline points for stories +- `events` β€” Library events +- `contact_messages` β€” Visitor inquiries +- `analytics` β€” View/engagement tracking +- `email_templates` β€” Email customization +- `message_responses` β€” Reply tracking + +--- ## Contributing -This is a private project. For questions or issues, please contact the development team. +This is a private project. For questions or collaboration, contact the maintainers. + +--- ## License -MIT License +MIT License. -## Author +--- -Developed as part of the Library Management System project. +**Author:** [frckbrice](https://github.com/frckbrice) β€” Library Management System diff --git a/config/cors/allowed-origins.ts b/config/cors/allowed-origins.ts index e31e4f2..ffc2e9d 100644 --- a/config/cors/allowed-origins.ts +++ b/config/cors/allowed-origins.ts @@ -1,3 +1,9 @@ +/** + * CORS Allowed Origins + * + * List of origins permitted by the CORS middleware. Use specific URLs in production; + * avoid "*" when credentials are used. + */ const allowOrigins = [ "http://localhost:3000", "*" diff --git a/config/cors/cors-options.ts b/config/cors/cors-options.ts index affa859..195ecb3 100644 --- a/config/cors/cors-options.ts +++ b/config/cors/cors-options.ts @@ -1,13 +1,21 @@ -import allowOrigins from "./allowed-origins" +/** + * CORS Configuration + * + * Restricts allowed origins to the list in allowed-origins (e.g. frontend URLs). + * Allows requests with no origin (e.g. same-origin, Postman). Credentials are enabled. + * + * @module config/cors/cors-options + */ +import allowOrigins from "./allowed-origins"; const corsOptions = { - origin: (origin: string | undefined, callback: Function) => { - - if ((origin && allowOrigins?.includes(origin)) || !origin) + origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { + if ((origin && allowOrigins?.includes(origin)) || !origin) { callback(null, true); - else - callback(new Error("\n\n Origin Not Allowed by Cors")) + } else { + callback(new Error("\n\n Origin Not Allowed by Cors")); + } }, credentials: true, optionSuccessStatus: 200, diff --git a/config/database/db.ts b/config/database/db.ts index 9a6166e..1df2f2b 100644 --- a/config/database/db.ts +++ b/config/database/db.ts @@ -1,9 +1,19 @@ +/** + * Database Connection & Drizzle ORM + * + * Chooses PostgreSQL (node-postgres) for local/development and Neon serverless + * for production. Exports { pool, db } for use by services and session store. + * Requires DATABASE_PRO_URL and DATABASE_URL in the environment. + * + * @module config/database/db + */ + import * as schema from "./schema"; import dotenv from 'dotenv'; import ws from "ws"; -import { Pool as PgPool } from 'pg'; // Local PostgreSQL +import { Pool as PgPool } from 'pg'; import { drizzle as pgDrizzle } from 'drizzle-orm/node-postgres'; -import { Pool as NeonPool, neonConfig } from '@neondatabase/serverless'; // Neon +import { Pool as NeonPool, neonConfig } from '@neondatabase/serverless'; import { drizzle as neonDrizzle } from 'drizzle-orm/neon-serverless'; dotenv.config(); @@ -17,10 +27,10 @@ if (!process.env.DATABASE_PRO_URL || !process.env.DATABASE_URL) { const isLocal = process.env.DATABASE_URL.includes('localhost') || process.env.NODE_ENV === 'development'; -let pool, db; +let pool: PgPool | NeonPool; +let db: ReturnType | ReturnType; if (isLocal) { - // Local PostgreSQL setup pool = new PgPool({ connectionString: process.env.DATABASE_PRO_URL, max: 20, @@ -28,9 +38,8 @@ if (isLocal) { connectionTimeoutMillis: 2000, ssl: true, }); - db = pgDrizzle(pool, { schema }); + db = pgDrizzle(pool as PgPool, { schema }); } else { - // Neon serverless setup neonConfig.webSocketConstructor = ws; pool = new NeonPool({ connectionString: process.env.DATABASE_PRO_URL, ssl: true }); db = neonDrizzle(pool, { schema }); diff --git a/config/database/seed.ts b/config/database/seed.ts index 04fd6a1..d72456e 100644 --- a/config/database/seed.ts +++ b/config/database/seed.ts @@ -39,7 +39,7 @@ async function seed() { // Add librarys - const [metMuseum] = await (db as any).insert(librarys).values({ + const [metMuseum] = await (db as any).insert(libraries).values({ name: "Metropolitan Museum of Art", description: "One of the world's largest and finest art librarys, with a collection spanning 5,000 years of world culture.", location: "1000 Fifth Avenue", @@ -56,7 +56,7 @@ async function seed() { coordinates: { lat: 40.7794, lng: -73.9632 } }).returning(); - const [natMuseum] = await (db as any).insert(librarys).values({ + const [natMuseum] = await (db as any).insert(libraries).values({ name: "National Gallery of History", description: "Explore artifacts spanning over 3,000 years of human civilization.", location: "Trafalgar Square", @@ -72,7 +72,7 @@ async function seed() { coordinates: { lat: 51.5089, lng: -0.1283 }, }).returning(); - const [musIMuseum] = await (db as any).insert(librarys).values({ + const [musIMuseum] = await (db as any).insert(libraries).values({ name: "Museum of Science and Innovation", description: "Interactive exhibits showcasing technological advances through the ages.", location: "2-3-1 Aomi, Koto", @@ -88,7 +88,7 @@ async function seed() { coordinates: { lat: 35.6196, lng: 139.7782 }, }).returning(); - const [louvreMuseum] = await (db as any).insert(librarys).values({ + const [louvreMuseum] = await (db as any).insert(libraries).values({ name: "Louvre Museum", description: "The world's largest art library and a historic monument in Paris, France.", location: "Rue de Rivoli", @@ -105,7 +105,7 @@ async function seed() { coordinates: { lat: 48.8606, lng: 2.3376 } }).returning(); - const [britishMuseum] = await (db as any).insert(librarys).values({ + const [britishMuseum] = await (db as any).insert(libraries).values({ name: "British Museum", description: "A public library dedicated to human history, art and culture, located in London.", location: "Great Russell Street", @@ -115,14 +115,14 @@ async function seed() { isActive: true, isApproved: true, isFeatured: false, - logoUrl: "https://images.unsplash.com/photo-1485842295075-1c7b2037114c?ixlib=rb-4.0.3&auto=format&fit=crop&w=80&h=80&q=80", + logoUrl: "https://images.unsplash.com/photo-1485842295075-1c7b2037114c?ixlib=rb-4.0.3&auto=format&fit=crop&w=80&h=80&q=80", featuredImageUrl: "https://images.unsplash.com/photo-1485842295075-1c7b2037114c?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=500&q=80", website: "https://www.britishlibrary.org", contactEmail: "info@britishlibrary.org", coordinates: { lat: 51.5194, lng: -0.1269 } }).returning(); - const [modernMuseum] = await (db as any).insert(librarys).values({ + const [modernMuseum] = await (db as any).insert(libraries).values({ name: "Museum of Modern Art", description: "One of the largest and most influential librarys of modern art in the world.", location: "11 West 53rd Street", diff --git a/config/database/storage.ts b/config/database/storage.ts index b3e9847..ff6481c 100644 --- a/config/database/storage.ts +++ b/config/database/storage.ts @@ -39,10 +39,10 @@ export interface IStorage { createUser(user: InsertUser): Promise; updateUser(id: string, data: Partial): Promise; - // Librarys + // Libraries getLibrary(id: string): Promise; getLibraryByName(name: string): Promise; - getLibrarys(options?: { + getLibraries(options?: { approved?: boolean; featured?: boolean; type?: string; @@ -50,7 +50,7 @@ export interface IStorage { limit?: number; offset?: number; }): Promise; - getTotalLibrarys(options?: { approved?: boolean; featured?: boolean; type?: string }): Promise; + getTotalLibraries(options?: { approved?: boolean; featured?: boolean; type?: string }): Promise; createLibrary(library: InsertLibrary): Promise; updateLibrary(id: string, data: Partial): Promise; @@ -198,22 +198,22 @@ export class MemStorage implements IStorage { public async initSampleData() { - // console.log("\n\nInitializing sample data..."); - // console.log("\n\nClearing existing data...") + // console.log("\n\nInitializing sample data..."); + // console.log("\n\nClearing existing data...") - // // Drop tables in the reverse order of their dependencies - // await db.delete(analytics); - // await db.delete(contactMessages); - // await db.delete(events); - // await db.delete(timelines); - // await db.delete(mediaItems); - // await db.delete(stories); - // await db.delete(users); - // await db.delete(librarys); - // await db.delete(emailTemplates); - // await db.delete(messageResponses); + // // Drop tables in the reverse order of their dependencies + // await db.delete(analytics); + // await db.delete(contactMessages); + // await db.delete(events); + // await db.delete(timelines); + // await db.delete(mediaItems); + // await db.delete(stories); + // await db.delete(users); + // await db.delete(librarys); + // await db.delete(emailTemplates); + // await db.delete(messageResponses); - // console.log("\n\nRebuilding tables...") + // console.log("\n\nRebuilding tables...") // Create super admin user this.createUser({ @@ -968,7 +968,7 @@ export class MemStorage implements IStorage { ); } - async getLibrarys(options?: { + async getLibraries(options?: { approved?: boolean; featured?: boolean; type?: string; @@ -1004,7 +1004,7 @@ export class MemStorage implements IStorage { return librarys; } - async getTotalLibrarys(options?: { + async getTotalLibraries(options?: { approved?: boolean; featured?: boolean; type?: string diff --git a/config/swagger.ts b/config/swagger.ts new file mode 100644 index 0000000..1b9b5c1 --- /dev/null +++ b/config/swagger.ts @@ -0,0 +1,2013 @@ +/** + * OpenAPI 3.0 spec for Library Management API. + * Served at /api-docs for interactive Swagger UI. + * Does not expose internal systems, IPs, or stack traces. + * + * Error codes (RFC 7807–aligned): VALIDATION_ERROR, AUTHENTICATION_ERROR, + * AUTHORIZATION_ERROR, NOT_FOUND, CONFLICT, RATE_LIMITED, INTERNAL_ERROR + */ + +const apiPath = '/api/v1'; +const isProduction = process.env.NODE_ENV === 'production'; +// Only use public base URL; never expose localhost or internal IPs in docs +const publicBaseUrl = + process.env.API_BASE_URL && (isProduction || process.env.API_BASE_URL.startsWith('http')) + ? process.env.API_BASE_URL.replace(/\/$/, '') + : null; + +const servers = publicBaseUrl + ? [{ url: publicBaseUrl, description: 'API server' }] + : [{ url: '/', description: 'Current host (relative)' }]; + +/** Reusable error response with standard codes. */ +const errorResponse = (description: string, exampleCode?: string, exampleMessage?: string) => ({ + description, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorResponse' }, + example: exampleCode + ? { success: false, error: exampleMessage || description, code: exampleCode, timestamp: new Date().toISOString() } + : undefined, + }, + }, +}); + +export const openApiDocument = { + openapi: '3.0.3', + info: { + title: 'Library Management API', + description: `REST API for library content managementβ€”authentication, CRUD for libraries, stories, timelines, events, media items, and contact messages. Role-based access: library admins manage their library; superadmins moderate platform-wide. + +**Features:** Session-based auth, rate limiting, multipart uploads, OpenAPI docs, standardized error responses. Collections use plural nouns (e.g. \`/libraries\`, \`/stories\`, \`/events\`, \`/media-items\`, \`/contact-messages\`).`, + version: '1.0.0', + contact: { + name: 'API Support', + email: process.env.GMAIL_APP_SUPPORT || 'support@example.com', + }, + license: { + name: 'MIT', + url: 'https://opensource.org/licenses/MIT', + }, + }, + servers, + tags: [ + { name: 'Auth', description: 'Login, session, logout' }, + { name: 'Libraries', description: 'Library CRUD and listing' }, + { name: 'Stories', description: 'Stories and timelines' }, + { name: 'Events', description: 'Library events' }, + { name: 'Media', description: 'Media items and galleries' }, + { name: 'Contact', description: 'Contact messages and replies' }, + { name: 'Admin', description: 'Library admin dashboard and content' }, + { name: 'Superadmin', description: 'Moderation and global admin' }, + { name: 'Settings', description: 'Platform settings' }, + { name: 'Maintenance', description: 'Health, maintenance mode, backups' }, + ], + paths: { + [`${apiPath}/auth/login`]: { + post: { + tags: ['Auth'], + summary: 'Login', + description: 'Authenticate with username and password. Sets session cookie (connect.sid). Rate-limited.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/LoginRequest' }, + }, + }, + }, + responses: { + '200': { + description: 'Login success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/LoginSuccessResponse' }, + }, + }, + }, + '400': errorResponse('Validation failed', 'VALIDATION_ERROR', 'Validation failed'), + '401': errorResponse('Invalid username or password', 'AUTHENTICATION_ERROR', 'Invalid username or password'), + '429': errorResponse('Too many login attempts. Please try again later.', 'RATE_LIMITED', 'Too many login attempts. Please try again later.'), + }, + }, + }, + [`${apiPath}/auth/session`]: { + get: { + tags: ['Auth'], + summary: 'Get current session', + description: 'Returns the current authenticated user or null if not logged in.', + responses: { + '200': { + description: 'Current user or null', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SessionResponse' }, + }, + }, + }, + }, + }, + }, + [`${apiPath}/auth/logout`]: { + post: { + tags: ['Auth'], + summary: 'Logout', + description: 'Destroys the session and clears the session cookie.', + responses: { + '200': { + description: 'Logged out successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { success: { type: 'boolean', example: true }, message: { type: 'string', example: 'Logged out successfully' } }, + }, + }, + }, + }, + }, + }, + }, + [`${apiPath}/libraries`]: { + get: { + tags: ['Libraries'], + summary: 'List libraries', + description: 'Public endpoint. Returns all libraries (optionally filtered).', + parameters: [ + { name: 'search', in: 'query', required: false, schema: { type: 'string' }, description: 'Search term' }, + ], + responses: { + '200': { + description: 'List of libraries', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Library' } }, + }, + }, + }, + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + post: { + tags: ['Libraries'], + summary: 'Create library', + description: 'Superadmin only. Creates a new library. New libraries require approval.', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { $ref: '#/components/schemas/LibraryCreateForm' }, + }, + }, + }, + responses: { + '201': { + description: 'Library created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SuccessDataResponse' }, + }, + }, + }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('Insufficient permissions', 'AUTHORIZATION_ERROR', 'Insufficient permissions'), + '400': errorResponse('Validation failed', 'VALIDATION_ERROR', 'Validation failed'), + '500': errorResponse('Failed to upload logo or featured image', 'INTERNAL_ERROR', 'Failed to upload logo or featured image'), + }, + }, + }, + [`${apiPath}/libraries/{id}`]: { + get: { + tags: ['Libraries'], + summary: 'Get library by ID', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Library', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Library' }, + }, + }, + }, + '404': errorResponse('Library not found', 'NOT_FOUND', 'Library not found'), + '500': errorResponse('Internal server error'), + }, + }, + patch: { + tags: ['Libraries'], + summary: 'Update library', + description: 'Library admin only. Can only edit own library.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + content: { + 'multipart/form-data': { + schema: { $ref: '#/components/schemas/LibraryUpdateForm' }, + }, + }, + }, + responses: { + '200': { + description: 'Updated library', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SuccessDataResponse' }, + }, + }, + }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('You can only edit your own library', 'AUTHORIZATION_ERROR', 'You can only edit your own library'), + '404': errorResponse('Library not found', 'NOT_FOUND', 'Library not found'), + '500': errorResponse('Failed to upload image'), + }, + }, + }, + [`${apiPath}/stories`]: { + get: { + tags: ['Stories'], + summary: 'List stories', + description: 'Public endpoint. Supports filtering by library, published, approved, featured, tags, pagination.', + parameters: [ + { name: 'libraryId', in: 'query', schema: { type: 'string', format: 'uuid' } }, + { name: 'published', in: 'query', schema: { type: 'string', enum: ['true', 'false'] }, description: 'Filter by published status' }, + { name: 'approved', in: 'query', schema: { type: 'string', enum: ['true', 'false'] } }, + { name: 'featured', in: 'query', schema: { type: 'string', enum: ['true', 'false'] } }, + { name: 'tag', in: 'query', schema: { type: 'string' }, description: 'Filter by tag (can repeat)' }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 50 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { + '200': { + description: 'List of stories', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Story' } }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/stories/tags`]: { + get: { + tags: ['Stories'], + summary: 'List story tags', + description: 'Returns all unique tags from published, approved stories.', + responses: { + '200': { + description: 'Sorted array of tag strings', + content: { + 'application/json': { + schema: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/stories/{id}`]: { + get: { + tags: ['Stories'], + summary: 'Get story by ID', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Story', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/StorySuccessResponse' }, + }, + }, + }, + '404': errorResponse('Story not found', 'NOT_FOUND', 'Story not found'), + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + }, + [`${apiPath}/admin/stories`]: { + post: { + tags: ['Stories'], + summary: 'Create story', + description: 'Library admin. Creates story for own library. New stories require superadmin approval.', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { $ref: '#/components/schemas/StoryCreateForm' }, + }, + }, + }, + responses: { + '201': { + description: 'Story created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SuccessDataResponse' }, + }, + }, + }, + '401': errorResponse('Authentication required'), + '400': errorResponse('Validation failed'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/admin/stories/{id}`]: { + delete: { + tags: ['Stories'], + summary: 'Delete story', + description: 'Library admin. Permanently deletes a story. Can only delete stories for own library.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '204': { description: 'Story deleted successfully' }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('You can only delete stories for your library', 'AUTHORIZATION_ERROR', 'You can only delete stories for your library'), + '404': errorResponse('Story not found', 'NOT_FOUND', 'Story not found'), + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + get: { + tags: ['Stories'], + summary: 'Get story (admin)', + description: 'For editing. Library admins can only access stories in their library.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Story', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/StorySuccessResponse' }, + }, + }, + }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('You can only access stories in your library', 'AUTHORIZATION_ERROR', 'You can only access stories in your library'), + '404': errorResponse('Story not found', 'NOT_FOUND', 'Story not found'), + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + patch: { + tags: ['Stories'], + summary: 'Update story', + description: 'Library admin. Can only edit stories for own library. Approval status is preserved (superadmin only).', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + content: { + 'multipart/form-data': { + schema: { $ref: '#/components/schemas/StoryUpdateForm' }, + }, + }, + }, + responses: { + '200': { + description: 'Updated story', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SuccessDataResponse' }, + }, + }, + }, + '401': errorResponse('Authentication required'), + '403': errorResponse('You can only edit stories for your library'), + '404': errorResponse('Story not found'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/admin/stories/{id}/timelines`]: { + get: { + tags: ['Stories'], + summary: 'Get timelines for story', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'List of timelines', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Timeline' } }, + }, + }, + }, + '401': errorResponse('Authentication required'), + '403': errorResponse('You can only access timelines for stories in your library'), + '404': errorResponse('Story not found'), + '500': errorResponse('Internal server error'), + }, + }, + post: { + tags: ['Stories'], + summary: 'Create timeline for story', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/TimelineCreate' }, + }, + }, + }, + responses: { + '201': { + description: 'Timeline created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Timeline' }, + }, + }, + }, + '401': errorResponse('Authentication required'), + '403': errorResponse('You can only add timelines to stories in your library'), + '404': errorResponse('Story not found'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/events`]: { + get: { + tags: ['Events'], + summary: 'List events', + description: 'Returns events. When authenticated, filters by library; otherwise all.', + parameters: [ + { name: 'libraryId', in: 'query', schema: { type: 'string', format: 'uuid' }, description: 'Filter by library' }, + ], + responses: { + '200': { + description: 'List of events', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Event' } }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + post: { + tags: ['Events'], + summary: 'Create event', + description: 'Library admin. Creates event for own library. New events require approval.', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { $ref: '#/components/schemas/EventCreateForm' }, + }, + }, + }, + responses: { + '201': { + description: 'Event created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Event' }, + }, + }, + }, + '400': errorResponse('Library ID required', 'VALIDATION_ERROR', 'Library ID required'), + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '500': errorResponse('Failed to upload event image', 'INTERNAL_ERROR', 'Failed to upload event image'), + }, + }, + }, + [`${apiPath}/events/{id}`]: { + get: { + tags: ['Events'], + summary: 'Get event by ID', + description: 'Returns a single event by ID. Public endpoint.', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Event', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Event' }, + }, + }, + }, + '404': errorResponse('Event not found', 'NOT_FOUND', 'Event not found'), + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + patch: { + tags: ['Events'], + summary: 'Update event', + description: 'Library admin. Can only edit events for own library.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + content: { + 'multipart/form-data': { + schema: { $ref: '#/components/schemas/EventUpdateForm' }, + }, + }, + }, + responses: { + '200': { + description: 'Updated event', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Event' }, + }, + }, + }, + '401': errorResponse('Authentication required'), + '403': errorResponse('Unauthorized - you can only edit events for your library'), + '404': errorResponse('Event not found'), + '500': errorResponse('Failed to upload event image'), + }, + }, + delete: { + tags: ['Events'], + summary: 'Delete event', + description: 'Library admin. Can only delete events for own library.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '204': { description: 'Event deleted successfully' }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('You can only delete events for your library', 'AUTHORIZATION_ERROR', 'You can only delete events for your library'), + '404': errorResponse('Event not found', 'NOT_FOUND', 'Event not found'), + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + }, + [`${apiPath}/media-items`]: { + get: { + tags: ['Media'], + summary: 'List media items', + parameters: [ + { name: 'libraryId', in: 'query', schema: { type: 'string', format: 'uuid' } }, + { name: 'galleryId', in: 'query', schema: { type: 'string' } }, + { name: 'approved', in: 'query', schema: { type: 'string', enum: ['true', 'false'] }, default: 'true' }, + { name: 'mediaType', in: 'query', schema: { type: 'string', enum: ['image', 'video', 'audio'] } }, + { name: 'tag', in: 'query', schema: { type: 'string' }, description: 'Filter by tag (can repeat)' }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 50 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { + '200': { + description: 'List of media items', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/MediaItem' } }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + post: { + tags: ['Media'], + summary: 'Upload media', + description: 'Library admin. Requires mediaFile or url. New media requires approval.', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { $ref: '#/components/schemas/MediaCreateForm' }, + }, + }, + }, + responses: { + '201': { + description: 'Media created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MediaItem' }, + }, + }, + }, + '400': errorResponse('Media URL or file is required, or Library ID required'), + '401': errorResponse('Authentication required'), + '403': errorResponse('Unauthorized - not logged in'), + '500': errorResponse('Failed to upload media file'), + }, + }, + }, + [`${apiPath}/media-items/{id}`]: { + get: { + tags: ['Media'], + summary: 'Get media by ID', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Media item', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MediaItem' }, + }, + }, + }, + '404': errorResponse('Media item not found'), + '500': errorResponse('Internal server error'), + }, + }, + patch: { + tags: ['Media'], + summary: 'Update media', + description: 'Library admin. Can only edit media for own library.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + content: { + 'multipart/form-data': { + schema: { $ref: '#/components/schemas/MediaUpdateForm' }, + }, + }, + }, + responses: { + '200': { + description: 'Updated media', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MediaItem' }, + }, + }, + }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('Unauthorized - you can only edit media for your library', 'AUTHORIZATION_ERROR', 'Unauthorized - you can only edit media for your library'), + '404': errorResponse('Media item not found', 'NOT_FOUND', 'Media item not found'), + '500': errorResponse('Failed to upload media file', 'INTERNAL_ERROR', 'Failed to upload media file'), + }, + }, + delete: { + tags: ['Media'], + summary: 'Delete media item', + description: 'Library admin. Permanently deletes a media item. Can only delete media for own library.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '204': { description: 'Media item deleted successfully' }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('Unauthorized - you can only delete media for your library', 'AUTHORIZATION_ERROR', 'Unauthorized - you can only delete media for your library'), + '404': errorResponse('Media item not found', 'NOT_FOUND', 'Media item not found'), + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + }, + [`${apiPath}/admin/media/tags`]: { + get: { + tags: ['Media'], + summary: 'List media tags', + description: 'Returns all unique media tags. Requires authentication.', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'Sorted array of tag strings', + content: { + 'application/json': { + schema: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + '403': errorResponse('Unauthorized - not logged in'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/admin/upload/image/{publicId}`]: { + delete: { + tags: ['Admin'], + summary: 'Delete image from Cloudinary', + description: 'Deletes an image by its Cloudinary public ID.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'publicId', in: 'path', required: true, schema: { type: 'string' }, description: 'Cloudinary public ID (may be URL-encoded)' }], + responses: { + '200': { + description: 'Image deleted successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { success: { type: 'boolean' }, message: { type: 'string' } }, + }, + }, + }, + }, + '401': errorResponse('Authentication required'), + '404': errorResponse('Image not found or already deleted'), + '500': errorResponse('Failed to delete image'), + '503': errorResponse('Cloudinary not configured'), + }, + }, + }, + [`${apiPath}/contact-messages`]: { + get: { + tags: ['Contact'], + summary: 'List contact messages', + description: 'Library admin only. Returns messages for own library.', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of contact messages', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/ContactMessage' } }, + }, + }, + }, + '401': errorResponse('Authentication required'), + '500': errorResponse('Internal server error'), + }, + }, + post: { + tags: ['Contact'], + summary: 'Submit contact message', + description: 'Public. Rate-limited. Creates a new contact message for a library.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ContactMessageCreate' }, + }, + }, + }, + responses: { + '201': { + description: 'Message created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ContactMessage' }, + }, + }, + }, + '400': errorResponse('Validation failed'), + '429': errorResponse('Too many contact submissions', 'RATE_LIMITED', 'Too many contact submissions. Please try again later.'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/contact-messages/{id}`]: { + get: { + tags: ['Contact'], + summary: 'Get contact message by ID', + description: 'Library admin only. Returns a single contact message for own library.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Contact message', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ContactMessage' }, + }, + }, + }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('Insufficient permissions', 'AUTHORIZATION_ERROR', 'Insufficient permissions'), + '404': errorResponse('Contact message not found', 'NOT_FOUND', 'Contact message not found'), + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + patch: { + tags: ['Contact'], + summary: 'Update contact message', + description: 'Library admin. Update status, isRead, etc.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + isRead: { type: 'boolean' }, + responseStatus: { type: 'string', enum: ['pending', 'responded', 'closed'] }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Updated message', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ContactMessage' }, + }, + }, + }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('Insufficient permissions', 'AUTHORIZATION_ERROR', 'Insufficient permissions'), + '404': errorResponse('Contact message not found', 'NOT_FOUND', 'Contact message not found'), + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + delete: { + tags: ['Contact'], + summary: 'Delete contact message', + description: 'Library admin only. Permanently deletes a contact message for own library.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '204': { description: 'Contact message deleted successfully' }, + '401': errorResponse('Authentication required', 'AUTHENTICATION_ERROR', 'Authentication required'), + '403': errorResponse('Insufficient permissions', 'AUTHORIZATION_ERROR', 'Insufficient permissions'), + '404': errorResponse('Contact message not found', 'NOT_FOUND', 'Contact message not found'), + '500': errorResponse('Internal server error', 'INTERNAL_ERROR', 'Internal server error'), + }, + }, + }, + [`${apiPath}/contact-messages/{id}/reply`]: { + post: { + tags: ['Contact'], + summary: 'Reply to contact message', + description: 'Library admin. Sends email to visitor and records response. Rate-limited for email.', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ContactReplyRequest' }, + }, + }, + }, + responses: { + '200': { + description: 'Reply sent', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MessageResponse' }, + }, + }, + }, + '400': errorResponse('Subject and message are required', 'VALIDATION_ERROR', 'Subject and message are required'), + '401': errorResponse('Unauthorized', 'AUTHENTICATION_ERROR', 'Unauthorized'), + '404': errorResponse('Message not found', 'NOT_FOUND', 'Message not found'), + '500': errorResponse('Failed to send email response', 'INTERNAL_ERROR', 'Failed to send email response'), + '429': errorResponse('Email rate limit exceeded', 'RATE_LIMITED', 'Email rate limit exceeded. Please try again later.'), + }, + }, + }, + [`${apiPath}/admin/dashboard/stats`]: { + get: { + tags: ['Admin'], + summary: 'Dashboard stats', + description: 'Library admin. Returns counts for stories, media, events, messages.', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'Stats object', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DashboardStats' }, + }, + }, + }, + '400': errorResponse('Library ID required'), + '401': errorResponse('Authentication required'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/admin/dashboard/analytics`]: { + get: { + tags: ['Admin'], + summary: 'Dashboard analytics', + description: 'Library admin. Returns visitor, content, engagement, and top performers data.', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'Analytics object', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DashboardAnalytics' }, + }, + }, + }, + '400': errorResponse('Library ID required'), + '401': errorResponse('Authentication required'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/admin/dashboard/activity`]: { + get: { + tags: ['Admin'], + summary: 'Recent activity', + description: 'Library admin. Returns recent stories, messages, events.', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'Recent activity array', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['story', 'message', 'event'] }, + title: { type: 'string' }, + timestamp: { type: 'string', format: 'date-time' }, + status: { type: 'string' }, + }, + }, + }, + }, + }, + }, + '400': errorResponse('Library ID required'), + '401': errorResponse('Authentication required'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/admin/galleries`]: { + get: { + tags: ['Admin'], + summary: 'List galleries', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of galleries', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Gallery' } }, + }, + }, + }, + '403': errorResponse('Unauthorized - not logged in'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/sadmin/stats`]: { + get: { + tags: ['Superadmin'], + summary: 'Superadmin stats', + description: 'Platform-wide stats: libraries, stories, media, users, recent activity.', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'Stats object', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SuperadminStats' }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/moderation/stories`]: { + get: { + tags: ['Superadmin'], + summary: 'Stories pending moderation', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of pending stories', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Story' } }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/moderation/media`]: { + get: { + tags: ['Superadmin'], + summary: 'Media pending moderation', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of pending media items', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/MediaItem' } }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/stories/{id}/approve`]: { + patch: { + tags: ['Superadmin'], + summary: 'Approve story', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Story approved', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Story' }, + }, + }, + }, + '404': errorResponse('Story not found'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/stories/{id}/reject`]: { + patch: { + tags: ['Superadmin'], + summary: 'Reject story', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Story rejected', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Story' }, + }, + }, + }, + '404': errorResponse('Story not found'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/media-items/{id}/approve`]: { + patch: { + tags: ['Superadmin'], + summary: 'Approve media item', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Media approved', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MediaItem' }, + }, + }, + }, + '404': errorResponse('Media item not found'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/media-items/{id}/reject`]: { + patch: { + tags: ['Superadmin'], + summary: 'Reject media item', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + responses: { + '200': { + description: 'Media rejected', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MediaItem' }, + }, + }, + }, + '404': errorResponse('Media item not found'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/libraries`]: { + get: { + tags: ['Superadmin'], + summary: 'List all libraries', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of libraries', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Library' } }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/users`]: { + get: { + tags: ['Superadmin'], + summary: 'List users', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of users (across all libraries)', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/User' } }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + post: { + tags: ['Superadmin'], + summary: 'Create user', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UserCreate' }, + }, + }, + }, + responses: { + '201': { + description: 'User created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + '400': errorResponse('Missing required fields'), + '409': errorResponse('Username or email already in use', 'CONFLICT', 'Username already in use. Please choose a different one.'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/users/{id}`]: { + patch: { + tags: ['Superadmin'], + summary: 'Update user', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UserUpdate' }, + }, + }, + }, + responses: { + '200': { + description: 'Updated user', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + '404': errorResponse('User not found'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/superadmin/users/{id}/reset-password`]: { + post: { + tags: ['Superadmin'], + summary: 'Reset user password', + security: [{ cookieAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['password'], + properties: { + password: { type: 'string', minLength: 8, description: 'New password (min 8 characters)' }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Password reset successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { message: { type: 'string', example: 'Password reset successfully' } }, + }, + }, + }, + }, + '400': errorResponse('Password is required'), + '404': errorResponse('User not found'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/settings`]: { + get: { + tags: ['Settings'], + summary: 'Get platform settings', + description: 'Returns general, security, email, content, appearance, notifications settings.', + responses: { + '200': { + description: 'Platform settings object', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PlatformSettings' }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + post: { + tags: ['Settings'], + summary: 'Update platform settings', + description: 'Merges provided updates with existing settings (in-memory; prefer DB in production).', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + description: 'Partial platform settings to merge', + }, + }, + }, + }, + responses: { + '200': { + description: 'Updated settings', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PlatformSettings' }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/settings/test-email`]: { + post: { + tags: ['Settings'], + summary: 'Test email configuration', + description: 'Sends a test email to verify SMTP/email setup.', + responses: { + '200': { + description: 'Test email sent', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { message: { type: 'string', example: 'Test email sent successfully' } }, + }, + }, + }, + }, + '500': errorResponse('Failed to send test email'), + }, + }, + }, + [`${apiPath}/health`]: { + get: { + tags: ['Maintenance'], + summary: 'Health check', + description: 'Verifies database connectivity. Returns system healthy/unhealthy.', + responses: { + '200': { + description: 'System healthy', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'system healthy' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + '500': { + description: 'System unhealthy', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'system unhealthy' }, + error: { type: 'string' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + }, + }, + }, + [`${apiPath}/maintenance/status`]: { + get: { + tags: ['Maintenance'], + summary: 'Maintenance status', + description: 'Returns maintenance mode, system health, metrics, windows, backup history.', + responses: { + '200': { + description: 'Maintenance status', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MaintenanceStatus' }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/maintenance/toggle`]: { + post: { + tags: ['Maintenance'], + summary: 'Toggle maintenance mode', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['enabled'], + properties: { + enabled: { type: 'boolean', description: 'Enable or disable maintenance mode' }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Maintenance mode toggled', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + maintenanceMode: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/maintenance/schedule`]: { + post: { + tags: ['Maintenance'], + summary: 'Schedule maintenance window', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['title', 'scheduledStart'], + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + scheduledStart: { type: 'string', format: 'date-time' }, + scheduledEnd: { type: 'string', format: 'date-time' }, + affectedServices: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Maintenance window scheduled', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'integer' }, + title: { type: 'string' }, + description: { type: 'string' }, + scheduledStart: { type: 'string', format: 'date-time' }, + scheduledEnd: { type: 'string', format: 'date-time', nullable: true }, + affectedServices: { type: 'array', items: { type: 'string' } }, + status: { type: 'string', default: 'scheduled' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + '400': errorResponse('Title and start time are required'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/maintenance/backup`]: { + post: { + tags: ['Maintenance'], + summary: 'Create backup', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['type'], + properties: { + type: { type: 'string', enum: ['database', 'files', 'full'], description: 'Backup type' }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Backup started', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'integer' }, + type: { type: 'string', enum: ['database', 'files', 'full'] }, + size: { type: 'string' }, + created: { type: 'string', format: 'date-time' }, + status: { type: 'string', default: 'running' }, + }, + }, + }, + }, + }, + '400': errorResponse('Invalid backup type'), + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/maintenance/backups`]: { + get: { + tags: ['Maintenance'], + summary: 'List backup history', + responses: { + '200': { + description: 'Backup history', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + type: { type: 'string' }, + size: { type: 'string' }, + created: { type: 'string', format: 'date-time' }, + status: { type: 'string' }, + }, + }, + }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + [`${apiPath}/maintenance/refresh`]: { + post: { + tags: ['Maintenance'], + summary: 'Refresh system status', + description: 'Re-checks system health for all services.', + responses: { + '200': { + description: 'Updated system health', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + systemHealth: { + type: 'array', + items: { + type: 'object', + properties: { + service: { type: 'string' }, + status: { type: 'string', enum: ['healthy', 'warning', 'unhealthy'] }, + uptime: { type: 'string' }, + responseTime: { type: 'integer' }, + lastCheck: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + }, + }, + }, + '500': errorResponse('Internal server error'), + }, + }, + }, + }, + components: { + securitySchemes: { + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'connect.sid', + description: 'Session cookie set after login. Required for authenticated endpoints.', + }, + }, + schemas: { + ErrorResponse: { + type: 'object', + required: ['success', 'error', 'timestamp'], + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string', description: 'Human-readable error message' }, + code: { + type: 'string', + enum: ['VALIDATION_ERROR', 'AUTHENTICATION_ERROR', 'AUTHORIZATION_ERROR', 'NOT_FOUND', 'CONFLICT', 'RATE_LIMITED', 'INTERNAL_ERROR'], + description: 'Machine-readable error code for client handling', + }, + errors: { + type: 'object', + additionalProperties: { type: 'array', items: { type: 'string' } }, + description: 'Field-level validation errors (when code is VALIDATION_ERROR)', + }, + timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp' }, + }, + }, + ValidationErrorResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string', example: 'Validation failed' }, + errors: { + type: 'object', + additionalProperties: { type: 'array', items: { type: 'string' } }, + }, + timestamp: { type: 'string', format: 'date-time' }, + }, + }, + LoginRequest: { + type: 'object', + required: ['username', 'password'], + properties: { + username: { type: 'string', minLength: 3, example: 'admin', description: 'Username (min 3 characters)' }, + password: { type: 'string', format: 'password', minLength: 6, description: 'Password (min 6 characters)' }, + }, + }, + LoginSuccessResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + username: { type: 'string' }, + fullName: { type: 'string' }, + email: { type: 'string', format: 'email' }, + role: { type: 'string', enum: ['library_admin', 'super_admin'] }, + libraryId: { type: 'string', format: 'uuid', nullable: true }, + }, + }, + }, + }, + SessionResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + oneOf: [ + { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + username: { type: 'string' }, + fullName: { type: 'string' }, + email: { type: 'string' }, + role: { type: 'string' }, + libraryId: { type: 'string' }, + }, + }, + { type: 'object', nullable: true }, + ], + description: 'Current user object or null if not logged in', + }, + }, + }, + SuccessDataResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { type: 'object', description: 'The created/updated resource' }, + }, + }, + Library: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + description: { type: 'string' }, + location: { type: 'string' }, + city: { type: 'string' }, + country: { type: 'string' }, + logoUrl: { type: 'string', format: 'uri', nullable: true }, + featuredImageUrl: { type: 'string', format: 'uri', nullable: true }, + website: { type: 'string', format: 'uri', nullable: true }, + isApproved: { type: 'boolean', default: false }, + isActive: { type: 'boolean', default: true }, + isFeatured: { type: 'boolean', default: false }, + libraryType: { type: 'string', enum: ['public', 'academic', 'special'] }, + coordinates: { type: 'object', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + LibraryCreateForm: { + type: 'object', + properties: { + name: { type: 'string', required: true }, + description: { type: 'string' }, + location: { type: 'string' }, + city: { type: 'string' }, + country: { type: 'string' }, + libraryType: { type: 'string' }, + website: { type: 'string' }, + logo: { type: 'string', format: 'binary', description: 'Logo image file' }, + featuredImage: { type: 'string', format: 'binary', description: 'Featured image file' }, + logoUrl: { type: 'string', description: 'Or provide URL instead of file' }, + featuredImageUrl: { type: 'string' }, + coordinates: { type: 'object' }, + }, + }, + LibraryUpdateForm: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + location: { type: 'string' }, + city: { type: 'string' }, + country: { type: 'string' }, + libraryType: { type: 'string' }, + website: { type: 'string' }, + logo: { type: 'string', format: 'binary' }, + featuredImage: { type: 'string', format: 'binary' }, + logoUrl: { type: 'string' }, + featuredImageUrl: { type: 'string' }, + coordinates: { type: 'object' }, + }, + }, + Story: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + libraryId: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + content: { type: 'string', description: 'Rich text (HTML)' }, + summary: { type: 'string' }, + featuredImageUrl: { type: 'string', format: 'uri', nullable: true }, + isPublished: { type: 'boolean', default: false }, + isApproved: { type: 'boolean', default: false }, + isFeatured: { type: 'boolean', default: false }, + tags: { type: 'array', items: { type: 'string' } }, + publishedAt: { type: 'string', format: 'date-time' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + StorySuccessResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/Story' }, + }, + }, + StoryCreateForm: { + type: 'object', + properties: { + title: { type: 'string', required: true }, + content: { type: 'string', required: true }, + summary: { type: 'string' }, + libraryId: { type: 'string', format: 'uuid' }, + featuredImage: { type: 'string', format: 'binary' }, + featuredImageUrl: { type: 'string' }, + isPublished: { type: 'boolean', default: false }, + tags: { type: 'array', items: { type: 'string' } }, + }, + }, + StoryUpdateForm: { + type: 'object', + properties: { + title: { type: 'string' }, + content: { type: 'string' }, + summary: { type: 'string' }, + featuredImage: { type: 'string', format: 'binary' }, + featuredImageUrl: { type: 'string' }, + isPublished: { type: 'boolean' }, + tags: { type: 'array', items: { type: 'string' } }, + }, + }, + Timeline: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + storyId: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + description: { type: 'string', nullable: true }, + timelinePoints: { type: 'array', description: 'Array of timeline point objects' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + TimelineCreate: { + type: 'object', + required: ['title', 'timelinePoints'], + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + timelinePoints: { type: 'array' }, + }, + }, + Event: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + libraryId: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + description: { type: 'string' }, + eventDate: { type: 'string', format: 'date-time' }, + endDate: { type: 'string', format: 'date-time', nullable: true }, + location: { type: 'string' }, + imageUrl: { type: 'string', format: 'uri', nullable: true }, + isPublished: { type: 'boolean', default: false }, + isApproved: { type: 'boolean', default: false }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + EventCreateForm: { + type: 'object', + properties: { + title: { type: 'string', required: true }, + description: { type: 'string', required: true }, + eventDate: { type: 'string', format: 'date-time', required: true }, + endDate: { type: 'string', format: 'date-time' }, + location: { type: 'string', required: true }, + eventImage: { type: 'string', format: 'binary' }, + imageUrl: { type: 'string' }, + }, + }, + EventUpdateForm: { + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + eventDate: { type: 'string', format: 'date-time' }, + endDate: { type: 'string', format: 'date-time' }, + location: { type: 'string' }, + eventImage: { type: 'string', format: 'binary' }, + imageUrl: { type: 'string' }, + isPublished: { type: 'boolean' }, + }, + }, + MediaItem: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + libraryId: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + description: { type: 'string', nullable: true }, + mediaType: { type: 'string', enum: ['image', 'video', 'audio'] }, + url: { type: 'string', format: 'uri' }, + galleryId: { type: 'string', nullable: true }, + tags: { type: 'array', items: { type: 'string' } }, + isApproved: { type: 'boolean', default: false }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + MediaCreateForm: { + type: 'object', + properties: { + title: { type: 'string', required: true }, + description: { type: 'string' }, + mediaType: { type: 'string', enum: ['image', 'video', 'audio'], required: true }, + mediaFile: { type: 'string', format: 'binary', description: 'File upload' }, + url: { type: 'string', description: 'Or provide URL if no file' }, + galleryId: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + }, + }, + MediaUpdateForm: { + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + mediaType: { type: 'string', enum: ['image', 'video', 'audio'] }, + mediaFile: { type: 'string', format: 'binary' }, + url: { type: 'string' }, + galleryId: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + }, + }, + Gallery: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + libraryId: { type: 'string' }, + mediaCount: { type: 'integer' }, + }, + }, + ContactMessage: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + libraryId: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + subject: { type: 'string' }, + message: { type: 'string' }, + isRead: { type: 'boolean', default: false }, + responseStatus: { type: 'string', enum: ['pending', 'responded', 'closed'], default: 'pending' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + ContactMessageCreate: { + type: 'object', + required: ['name', 'email', 'subject', 'message', 'libraryId'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + email: { type: 'string', format: 'email' }, + subject: { type: 'string', minLength: 1, maxLength: 200 }, + message: { type: 'string', minLength: 1, maxLength: 5000 }, + libraryId: { type: 'string', format: 'uuid' }, + }, + }, + ContactReplyRequest: { + type: 'object', + required: ['subject', 'message'], + properties: { + subject: { type: 'string', minLength: 1, maxLength: 200 }, + message: { type: 'string', minLength: 1, maxLength: 5000 }, + }, + }, + MessageResponse: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + contactMessageId: { type: 'string', format: 'uuid' }, + respondedBy: { type: 'string', format: 'uuid' }, + subject: { type: 'string' }, + message: { type: 'string' }, + emailSent: { type: 'boolean', default: false }, + emailSentAt: { type: 'string', format: 'date-time' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + DashboardStats: { + type: 'object', + properties: { + totalStories: { type: 'integer' }, + publishedStories: { type: 'integer' }, + totalMedia: { type: 'integer' }, + approvedMedia: { type: 'integer' }, + totalEvents: { type: 'integer' }, + upcomingEvents: { type: 'integer' }, + totalMessages: { type: 'integer' }, + unreadMessages: { type: 'integer' }, + }, + }, + DashboardAnalytics: { + type: 'object', + properties: { + visitorData: { type: 'array' }, + contentData: { type: 'array' }, + engagementData: { type: 'array' }, + topPerformers: { type: 'object' }, + }, + }, + SuperadminStats: { + type: 'object', + properties: { + totalLibraries: { type: 'integer' }, + pendingLibraries: { type: 'integer' }, + totalStories: { type: 'integer' }, + pendingStories: { type: 'integer' }, + totalMedia: { type: 'integer' }, + uniqueGalleries: { type: 'integer' }, + totalUsers: { type: 'integer' }, + activeUsers: { type: 'integer' }, + recentActivity: { type: 'array' }, + }, + }, + User: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + username: { type: 'string' }, + email: { type: 'string', format: 'email' }, + fullName: { type: 'string' }, + role: { type: 'string', enum: ['library_admin', 'super_admin'] }, + libraryId: { type: 'string', format: 'uuid', nullable: true }, + isActive: { type: 'boolean', default: true }, + lastLoginAt: { type: 'string', format: 'date-time' }, + }, + }, + UserCreate: { + type: 'object', + required: ['username', 'password', 'email', 'fullName', 'role'], + properties: { + username: { type: 'string', minLength: 3, maxLength: 50 }, + password: { type: 'string', minLength: 8, format: 'password' }, + email: { type: 'string', format: 'email' }, + fullName: { type: 'string', minLength: 1, maxLength: 100 }, + role: { type: 'string', enum: ['library_admin', 'super_admin'] }, + libraryId: { type: 'string', format: 'uuid', nullable: true }, + isActive: { type: 'boolean', default: true }, + }, + }, + UserUpdate: { + type: 'object', + properties: { + username: { type: 'string', minLength: 3, maxLength: 50 }, + email: { type: 'string', format: 'email' }, + fullName: { type: 'string', minLength: 1, maxLength: 100 }, + role: { type: 'string', enum: ['library_admin', 'super_admin'] }, + libraryId: { type: 'string', format: 'uuid', nullable: true }, + isActive: { type: 'boolean' }, + }, + }, + PlatformSettings: { + type: 'object', + properties: { + general: { + type: 'object', + properties: { + siteName: { type: 'string', default: 'Library Digital Platform' }, + siteDescription: { type: 'string' }, + contactEmail: { type: 'string' }, + supportEmail: { type: 'string' }, + defaultLanguage: { type: 'string', default: 'en' }, + timezone: { type: 'string', default: 'UTC' }, + allowRegistration: { type: 'boolean', default: true }, + maintenanceMode: { type: 'boolean', default: false }, + }, + }, + security: { + type: 'object', + properties: { + passwordMinLength: { type: 'integer', default: 8 }, + sessionTimeout: { type: 'integer', default: 24 }, + maxLoginAttempts: { type: 'integer', default: 5 }, + }, + }, + email: { type: 'object' }, + content: { type: 'object' }, + appearance: { type: 'object' }, + notifications: { type: 'object' }, + }, + }, + MaintenanceStatus: { + type: 'object', + properties: { + maintenanceMode: { type: 'boolean' }, + systemHealth: { + type: 'array', + items: { + type: 'object', + properties: { + service: { type: 'string' }, + status: { type: 'string', enum: ['healthy', 'warning', 'unhealthy'] }, + uptime: { type: 'string' }, + responseTime: { type: 'integer' }, + lastCheck: { type: 'string', format: 'date-time' }, + }, + }, + }, + systemMetrics: { + type: 'object', + properties: { + cpuUsage: { type: 'integer' }, + memoryUsage: { type: 'integer' }, + diskUsage: { type: 'integer' }, + networkTraffic: { type: 'string' }, + }, + }, + maintenanceWindows: { type: 'array' }, + backupHistory: { type: 'array' }, + }, + }, + }, + }, +}; diff --git a/index.ts b/index.ts index bde2f03..e262859 100644 --- a/index.ts +++ b/index.ts @@ -1,18 +1,31 @@ +/** + * Library Management API - Application Entry Point + * + * Bootstraps the Express server with session management (PostgreSQL or in-memory), + * CORS, JSON body parsing, request logging, and API routes. The global error + * handler is registered after routes so all errors are caught and formatted. + * + * @module index + */ + import express, { type Request, Response, NextFunction } from "express"; import session from "express-session"; import MemoryStore from "memorystore"; import connectPgSimple from "connect-pg-simple"; import dbPool from "./config/database/db"; -import { registerRoutes } from "./server/routes"; +import { registerRoutes } from "./src/routes"; import { Pool as NeonPool } from "@neondatabase/serverless"; import { Pool } from "pg"; -import errorHandler from "./middlewares/errors/error-handler"; -import cors from 'cors'; +import errorHandler from "./src/middlewares/error-handler"; +import helmet from "helmet"; +import cors from "cors"; import { MemStorage } from "./config/database/storage"; import corsOptions from "./config/cors/cors-options"; +import swaggerUi from "swagger-ui-express"; +import { openApiDocument } from "./config/swagger"; -// Declare session data +/** Extend express-session with app-specific session payload */ declare module "express-session" { interface SessionData { user?: { @@ -26,22 +39,39 @@ declare module "express-session" { } } -const PORT = process.env.PORT || 5500;; +const PORT = process.env.PORT || 5500; // Setup session stores const MemoryStoreSession = MemoryStore(session); const PgStore = connectPgSimple(session); const pool: any | Pool | NeonPool = dbPool.pool; -// Create express app const app = express(); + +/** OpenAPI docs (Swagger UI) – interact with the API at GET /api-docs */ +app.use( + "/api-docs", + swaggerUi.serve, + swaggerUi.setup(openApiDocument, { + customSiteTitle: "Library Management API - Documentation", + swaggerOptions: { + displayRequestDuration: true, + docExpansion: "list", + filter: true, + showExtensions: true, + persistAuthorization: true, + }, + }) +); + +/** Security headers (X-Content-Type-Options, X-Frame-Options, etc.) */ +app.use(helmet({ contentSecurityPolicy: false })); // Disable CSP for API-only backend + app.use(express.json()); app.use(express.urlencoded({ extended: false })); - -// cors app.use(cors(corsOptions)); -// Session middleware +/** Session middleware: persistent in production (PgStore), in-memory otherwise */ app.use(session({ store: process.env.DATAAPI_URL ? new PgStore({ @@ -92,14 +122,10 @@ app.use((req, res, next) => { next(); }); -// Main async block (async () => { const server = await registerRoutes("/api/v1", app); - - // Error handler app.use(errorHandler); - // Start server server.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}`); }).on("error", () => { diff --git a/jest.config.js b/jest.config.js index ab96a7c..d40fa92 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,15 +1,13 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/tests', '/src', '/services', '/middlewares', '/config'], + roots: ['/tests', '/src', '/config'], testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], transform: { '^.+\\.ts$': 'ts-jest', }, collectCoverageFrom: [ 'src/**/*.ts', - 'services/**/*.ts', - 'middlewares/**/*.ts', 'config/**/*.ts', '!**/*.d.ts', '!**/node_modules/**', @@ -24,5 +22,6 @@ module.exports = { }, setupFilesAfterEnv: ['/tests/setup.ts'], testTimeout: 10000, + forceExit: true, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], }; diff --git a/middlewares/errors/error-handler.ts b/middlewares/errors/error-handler.ts deleted file mode 100644 index 715e0b2..0000000 --- a/middlewares/errors/error-handler.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { logEvents } from "../logger"; - -const errorHandler = (err: any, req: any, res: any, next: any) => { - if (logEvents) - logEvents( - `${err.name}:${err.message}\t${req.method}\t${req.url}\t${req.headers.origin}`, 'errLog.log'); - - console.error(err.stack); - const statusCode = res.statusCode ? res.statusCode : 500; // Server Error - res.status(statusCode).send({ - message: err.message, - stack: process.env.NODE_ENV === 'production' ? 'πŸ₯ž' : err.stack, - method: req.method, - origin: req.headers.origin, - timestamp: new Date().toISOString(), - }); - - next(err); -}; - -export default errorHandler; diff --git a/middlewares/logger.ts b/middlewares/logger.ts deleted file mode 100644 index 4791a77..0000000 --- a/middlewares/logger.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { format } from 'date-fns'; -import { v4 as UUIDV4 } from 'uuid'; - -import fs from 'fs'; -import path from 'path'; - -const fsPromises = fs.promises; - -const logEvents = async (message: string, logFileName: string) => { - const dateTime = `${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}`; - const logItem = `${dateTime}\t${UUIDV4()}\t${message}\n`; - const logPath = path.join(__dirname, '..', 'logs'); - - try { - if (!fs.existsSync(logPath)) { - await fsPromises.mkdir(logPath); - } - await fsPromises.appendFile(path.join(__dirname, '..', 'logs', logFileName), logItem); - } catch (err) { - console.error(err); - } -}; - -const logger = (req: any, res: any, next: any) => { - logEvents(`${req.method}\t${req.headers.origin}\t${req.url}`, 'reqLog.log'); - console.log(`${req.method} ${req.path}`); - next(); -}; - -export default logger; -export { logEvents }; \ No newline at end of file diff --git a/package.json b/package.json index 012cb87..771ae7f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "nodemon --exec ts-node index.ts", "start": "node dist/index.js", - "prebuild": "find node_modules -type d -name 'sqlite-core' -exec rm -rf {} +", + "prebuild": "node -e \"try{require('fs').rmSync(require('path').join(process.cwd(),'node_modules','drizzle-orm','sqlite-core'),{recursive:true,force:true})}catch(e){}\"", "build": "tsc", "build:prod": "NODE_ENV=production tsc", "check": "tsc --noEmit", @@ -37,6 +37,7 @@ "express": "^4.21.2", "express-rate-limit": "^7.5.0", "express-session": "^1.18.1", + "helmet": "^8.0.0", "jest": "^30.0.3", "memorystore": "^1.6.7", "multer": "^2.0.1", @@ -51,10 +52,13 @@ "uuid": "^11.1.0", "ws": "^8.18.0", "zod": "^3.24.2", - "zod-validation-error": "^3.4.0" + "zod-validation-error": "^3.4.0", + "winston": "^3.17.0", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@types/connect-pg-simple": "^7.0.3", + "@types/swagger-ui-express": "^4.1.6", "@types/cors": "^2.8.19", "@types/express": "4.17.21", "@types/express-session": "^1.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7526d7c..5c518c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@jridgewell/trace-mapping': specifier: ^0.3.25 - version: 0.3.25 + version: 0.3.31 '@neondatabase/serverless': specifier: ^0.10.4 version: 0.10.4 @@ -28,58 +28,61 @@ importers: version: 6.0.0 cloudinary: specifier: ^2.7.0 - version: 2.7.0 + version: 2.9.0 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) connect-pg-simple: specifier: ^10.0.0 version: 10.0.0 cors: specifier: ^2.8.5 - version: 2.8.5 + version: 2.8.6 date-fns: specifier: ^3.6.0 version: 3.6.0 dotenv: specifier: ^16.5.0 - version: 16.5.0 + version: 16.6.1 drizzle-orm: specifier: ^0.39.3 - version: 0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.0) + version: 0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0) drizzle-zod: specifier: ^0.7.0 - version: 0.7.1(drizzle-orm@0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.0))(zod@3.25.64) + version: 0.7.1(drizzle-orm@0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0))(zod@3.25.76) express: specifier: ^4.21.2 - version: 4.21.2 + version: 4.22.1 express-rate-limit: specifier: ^7.5.0 - version: 7.5.0(express@4.21.2) + version: 7.5.1(express@4.22.1) express-session: specifier: ^1.18.1 - version: 1.18.1 + version: 1.19.0 + helmet: + specifier: ^8.0.0 + version: 8.1.0 jest: specifier: ^30.0.3 - version: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + version: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) memorystore: specifier: ^1.6.7 version: 1.6.7 multer: specifier: ^2.0.1 - version: 2.0.1 + version: 2.0.2 multer-storage-cloudinary: specifier: ^4.0.0 - version: 4.0.0(cloudinary@2.7.0) + version: 4.0.0(cloudinary@2.9.0) mysql2: specifier: ^3.14.1 - version: 3.14.1 + version: 3.16.3 nodemailer: specifier: ^7.0.4 - version: 7.0.4 + version: 7.0.13 nodemon: specifier: ^3.1.10 - version: 3.1.10 + version: 3.1.11 passport: specifier: ^0.7.0 version: 0.7.0 @@ -88,22 +91,28 @@ importers: version: 1.0.0 pg: specifier: ^8.16.0 - version: 8.16.0 + version: 8.18.0 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@4.22.1) ts-jest: specifier: ^29.4.0 - version: 29.4.0(@babel/core@7.27.7)(@jest/transform@30.0.2)(@jest/types@30.0.1)(babel-jest@30.0.2(@babel/core@7.27.7))(esbuild@0.25.5)(jest-util@30.0.2)(jest@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)))(typescript@5.6.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.25.12)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)))(typescript@5.6.3) uuid: specifier: ^11.1.0 version: 11.1.0 + winston: + specifier: ^3.17.0 + version: 3.19.0 ws: specifier: ^8.18.0 - version: 8.18.2(bufferutil@4.0.9) + version: 8.19.0(bufferutil@4.1.0) zod: specifier: ^3.24.2 - version: 3.25.64 + version: 3.25.76 zod-validation-error: specifier: ^3.4.0 - version: 3.5.0(zod@3.25.64) + version: 3.5.4(zod@3.25.76) devDependencies: '@types/connect-pg-simple': specifier: ^7.0.3 @@ -122,7 +131,7 @@ importers: version: 20.16.11 '@types/nodemailer': specifier: ^6.4.17 - version: 6.4.17 + version: 6.4.22 '@types/passport': specifier: ^1.0.16 version: 1.0.17 @@ -131,7 +140,10 @@ importers: version: 1.0.38 '@types/pg': specifier: ^8.15.4 - version: 8.15.4 + version: 8.16.0 + '@types/swagger-ui-express': + specifier: ^4.1.6 + version: 4.1.8 '@types/ws': specifier: ^8.5.13 version: 8.18.1 @@ -140,26 +152,26 @@ importers: version: 0.30.6 esbuild: specifier: ^0.25.0 - version: 0.25.5 + version: 0.25.12 postcss: specifier: ^8.4.47 - version: 8.5.5 + version: 8.5.6 tailwindcss: specifier: ^3.4.17 - version: 3.4.17(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + version: 3.4.19(tsx@4.21.0) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.16.11)(typescript@5.6.3) tsx: specifier: ^4.19.1 - version: 4.20.3 + version: 4.21.0 typescript: specifier: 5.6.3 version: 5.6.3 optionalDependencies: bufferutil: specifier: ^4.0.8 - version: 4.0.9 + version: 4.1.0 packages: @@ -167,62 +179,62 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.27.7': - resolution: {integrity: sha512-xgu/ySj2mTiUFmdE9yCMfBxLp4DHd5DwmbbD05YAuICfodYT3VvRxbrh81LGQ/8UpSdtMdfKMn3KouYDX59DGQ==} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/core@7.27.7': - resolution: {integrity: sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.27.5': - resolution: {integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.27.3': - resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==} + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.27.6': - resolution: {integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.27.7': - resolution: {integrity: sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true @@ -247,8 +259,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -263,8 +275,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -311,42 +323,49 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.27.1': - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.27.7': - resolution: {integrity: sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw==} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.27.7': - resolution: {integrity: sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@emnapi/core@1.4.3': - resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.4.3': - resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - '@emnapi/wasi-threads@1.0.2': - resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==} + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} @@ -362,8 +381,14 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.5': - resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -380,8 +405,14 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.5': - resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -398,8 +429,14 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.5': - resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -416,8 +453,14 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.5': - resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -434,8 +477,14 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.5': - resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -452,8 +501,14 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.5': - resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -470,8 +525,14 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.5': - resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -488,8 +549,14 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.5': - resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -506,8 +573,14 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.5': - resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -524,8 +597,14 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.5': - resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -542,8 +621,14 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.5': - resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -560,8 +645,14 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.5': - resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -578,8 +669,14 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.5': - resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -596,8 +693,14 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.5': - resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -614,8 +717,14 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.5': - resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -632,8 +741,14 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.5': - resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -650,14 +765,26 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.5': - resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.5': - resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -674,14 +801,26 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.5': - resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.5': - resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -698,12 +837,30 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.5': - resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -716,8 +873,14 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.5': - resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -734,8 +897,14 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.5': - resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -752,8 +921,14 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.5': - resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -770,8 +945,14 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.5': - resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -788,12 +969,12 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - '@jest/console@30.0.2': - resolution: {integrity: sha512-krGElPU0FipAqpVZ/BRZOy0MZh/ARdJ0Nj+PiH1ykFY1+VpBlYNLjdjVA5CFKxnKR6PFqFutO4Z7cdK9BlGiDA==} + '@jest/console@30.2.0': + resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/core@30.0.3': - resolution: {integrity: sha512-Mgs1N+NSHD3Fusl7bOq1jyxv1JDAUwjy+0DhVR93Q6xcBP9/bAQ+oZhXb5TTnP5sQzAHgb7ROCKQ2SnovtxYtg==} + '@jest/core@30.2.0': + resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -805,36 +986,36 @@ packages: resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/environment@30.0.2': - resolution: {integrity: sha512-hRLhZRJNxBiOhxIKSq2UkrlhMt3/zVFQOAi5lvS8T9I03+kxsbflwHJEF+eXEYXCrRGRhHwECT7CDk6DyngsRA==} + '@jest/environment@30.2.0': + resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect-utils@30.0.3': - resolution: {integrity: sha512-SMtBvf2sfX2agcT0dA9pXwcUrKvOSDqBY4e4iRfT+Hya33XzV35YVg+98YQFErVGA/VR1Gto5Y2+A6G9LSQ3Yg==} + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect@30.0.3': - resolution: {integrity: sha512-73BVLqfCeWjYWPEQoYjiRZ4xuQRhQZU0WdgvbyXGRHItKQqg5e6mt2y1kVhzLSuZpmUnccZHbGynoaL7IcLU3A==} + '@jest/expect@30.2.0': + resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/fake-timers@30.0.2': - resolution: {integrity: sha512-jfx0Xg7l0gmphTY9UKm5RtH12BlLYj/2Plj6wXjVW5Era4FZKfXeIvwC67WX+4q8UCFxYS20IgnMcFBcEU0DtA==} + '@jest/fake-timers@30.2.0': + resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/get-type@30.0.1': - resolution: {integrity: sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==} + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/globals@30.0.3': - resolution: {integrity: sha512-fIduqNyYpMeeSr5iEAiMn15KxCzvrmxl7X7VwLDRGj7t5CoHtbF+7K3EvKk32mOUIJ4kIvFRlaixClMH2h/Vaw==} + '@jest/globals@30.2.0': + resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/pattern@30.0.1': resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/reporters@30.0.2': - resolution: {integrity: sha512-l4QzS/oKf57F8WtPZK+vvF4Io6ukplc6XgNFu4Hd/QxaLEO9f+8dSFzUua62Oe0HKlCUjKHpltKErAgDiMJKsA==} + '@jest/reporters@30.2.0': + resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -842,57 +1023,55 @@ packages: node-notifier: optional: true - '@jest/schemas@30.0.1': - resolution: {integrity: sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==} + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/snapshot-utils@30.0.1': - resolution: {integrity: sha512-6Dpv7vdtoRiISEFwYF8/c7LIvqXD7xDXtLPNzC2xqAfBznKip0MQM+rkseKwUPUpv2PJ7KW/YsnwWXrIL2xF+A==} + '@jest/snapshot-utils@30.2.0': + resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/source-map@30.0.1': resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-result@30.0.2': - resolution: {integrity: sha512-KKMuBKkkZYP/GfHMhI+cH2/P3+taMZS3qnqqiPC1UXZTJskkCS+YU/ILCtw5anw1+YsTulDHFpDo70mmCedW8w==} + '@jest/test-result@30.2.0': + resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-sequencer@30.0.2': - resolution: {integrity: sha512-fbyU5HPka0rkalZ3MXVvq0hwZY8dx3Y6SCqR64zRmh+xXlDeFl0IdL4l9e7vp4gxEXTYHbwLFA1D+WW5CucaSw==} + '@jest/test-sequencer@30.2.0': + resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/transform@30.0.2': - resolution: {integrity: sha512-kJIuhLMTxRF7sc0gPzPtCDib/V9KwW3I2U25b+lYCYMVqHHSrcZopS8J8H+znx9yixuFv+Iozl8raLt/4MoxrA==} + '@jest/transform@30.2.0': + resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/types@30.0.1': - resolution: {integrity: sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==} + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jridgewell/gen-mapping@0.3.8': - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} - engines: {node: '>=6.0.0'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@napi-rs/wasm-runtime@0.2.11': - resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} '@neondatabase/serverless@0.10.4': resolution: {integrity: sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==} @@ -909,19 +1088,19 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@petamoriken/float16@3.9.2': - resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} + '@petamoriken/float16@3.9.3': + resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@pkgr/core@0.2.7': - resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@radix-ui/primitive@1.1.2': - resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} @@ -941,8 +1120,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.14': - resolution: {integrity: sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==} + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -954,8 +1133,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dismissable-layer@1.1.10': - resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==} + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -967,8 +1146,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-guards@1.1.2': - resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1011,8 +1190,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.4': - resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1037,6 +1216,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -1046,6 +1238,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1091,8 +1292,11 @@ packages: '@types/react': optional: true - '@sinclair/typebox@0.34.37': - resolution: {integrity: sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==} + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -1100,8 +1304,11 @@ packages: '@sinonjs/fake-timers@13.0.5': resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} - '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} '@tsconfig/node12@1.0.11': resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} @@ -1112,8 +1319,8 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.9.0': - resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1124,8 +1331,8 @@ packages: '@types/babel__template@7.4.4': resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - '@types/babel__traverse@7.20.7': - resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} '@types/bcrypt@5.0.2': resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} @@ -1142,8 +1349,8 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} - '@types/express-serve-static-core@4.19.6': - resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} '@types/express-session@1.18.2': resolution: {integrity: sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==} @@ -1166,17 +1373,14 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/multer@1.4.13': resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} '@types/node@20.16.11': resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==} - '@types/nodemailer@6.4.17': - resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} + '@types/nodemailer@6.4.22': + resolution: {integrity: sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==} '@types/passport-local@1.0.38': resolution: {integrity: sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==} @@ -1190,11 +1394,8 @@ packages: '@types/pg@8.11.6': resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} - '@types/pg@8.15.4': - resolution: {integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==} - - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -1202,127 +1403,125 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} - peerDependencies: - '@types/react': ^18.0.0 - - '@types/react@18.3.23': - resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} - - '@types/send@0.17.5': - resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - '@types/serve-static@1.15.8': - resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/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/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@unrs/resolver-binding-android-arm-eabi@1.9.2': - resolution: {integrity: sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] os: [android] - '@unrs/resolver-binding-android-arm64@1.9.2': - resolution: {integrity: sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==} + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} cpu: [arm64] os: [android] - '@unrs/resolver-binding-darwin-arm64@1.9.2': - resolution: {integrity: sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==} + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} cpu: [arm64] os: [darwin] - '@unrs/resolver-binding-darwin-x64@1.9.2': - resolution: {integrity: sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==} + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} cpu: [x64] os: [darwin] - '@unrs/resolver-binding-freebsd-x64@1.9.2': - resolution: {integrity: sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==} + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} cpu: [x64] os: [freebsd] - '@unrs/resolver-binding-linux-arm-gnueabihf@1.9.2': - resolution: {integrity: sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==} + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm-musleabihf@1.9.2': - resolution: {integrity: sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==} + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm64-gnu@1.9.2': - resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==} + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - '@unrs/resolver-binding-linux-arm64-musl@1.9.2': - resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==} + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - '@unrs/resolver-binding-linux-ppc64-gnu@1.9.2': - resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==} + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - '@unrs/resolver-binding-linux-riscv64-gnu@1.9.2': - resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==} + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - '@unrs/resolver-binding-linux-riscv64-musl@1.9.2': - resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==} + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - '@unrs/resolver-binding-linux-s390x-gnu@1.9.2': - resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==} + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - '@unrs/resolver-binding-linux-x64-gnu@1.9.2': - resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==} + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - '@unrs/resolver-binding-linux-x64-musl@1.9.2': - resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==} + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - '@unrs/resolver-binding-wasm32-wasi@1.9.2': - resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==} + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@unrs/resolver-binding-win32-arm64-msvc@1.9.2': - resolution: {integrity: sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==} + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} cpu: [arm64] os: [win32] - '@unrs/resolver-binding-win32-ia32-msvc@1.9.2': - resolution: {integrity: sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==} + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} cpu: [ia32] os: [win32] - '@unrs/resolver-binding-win32-x64-msvc@1.9.2': - resolution: {integrity: sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==} + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} cpu: [x64] os: [win32] @@ -1347,8 +1546,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.1.0: - resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -1359,8 +1558,8 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} any-promise@1.3.0: @@ -1396,34 +1595,38 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} - babel-jest@30.0.2: - resolution: {integrity: sha512-A5kqR1/EUTidM2YC2YMEUDP2+19ppgOwK0IAd9Swc3q2KqFb5f9PtRUXVeZcngu0z5mDMyZ9zH2huJZSOMLiTQ==} + babel-jest@30.2.0: + resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: - '@babel/core': ^7.11.0 + '@babel/core': ^7.11.0 || ^8.0.0-0 - babel-plugin-istanbul@7.0.0: - resolution: {integrity: sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==} + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} engines: {node: '>=12'} - babel-plugin-jest-hoist@30.0.1: - resolution: {integrity: sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==} + babel-plugin-jest-hoist@30.2.0: + resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - babel-preset-current-node-syntax@1.1.0: - resolution: {integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==} + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^7.0.0 || ^8.0.0-0 - babel-preset-jest@30.0.1: - resolution: {integrity: sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==} + babel-preset-jest@30.2.0: + resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: - '@babel/core': ^7.11.0 + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + bcrypt@6.0.0: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} @@ -1432,8 +1635,8 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} brace-expansion@1.1.12: @@ -1446,8 +1649,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.25.1: - resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1461,8 +1664,8 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - bufferutil@4.0.9: - resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} engines: {node: '>=6.14.2'} busboy@1.6.0: @@ -1497,8 +1700,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001726: - resolution: {integrity: sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==} + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1512,19 +1715,19 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - ci-info@4.2.0: - resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} - cjs-module-lexer@2.1.0: - resolution: {integrity: sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - cloudinary@2.7.0: - resolution: {integrity: sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==} + cloudinary@2.9.0: + resolution: {integrity: sha512-F3iKMOy4y0zy0bi5JBp94SC7HY7i/ImfTPSUV07iJmRzH1Iz8WavFfOlJTR1zvYM/xKGoiGZ3my/zy64In0IQQ==} engines: {node: '>=9'} cmdk@1.1.1: @@ -1537,16 +1740,32 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - collect-v8-coverage@1.0.2: - resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1573,22 +1792,15 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} create-require@1.1.1: @@ -1603,9 +1815,6 @@ packages: engines: {node: '>=4'} hasBin: true - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} @@ -1617,8 +1826,8 @@ packages: supports-color: optional: true - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -1626,8 +1835,8 @@ packages: supports-color: optional: true - dedent@1.6.0: - resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==} + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: @@ -1660,15 +1869,15 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dotenv@16.5.0: - resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} drizzle-kit@0.30.6: @@ -1777,13 +1986,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} - hasBin: true - - electron-to-chromium@1.5.176: - resolution: {integrity: sha512-2nDK9orkm7M9ZZkjO3PjbEd3VUulQLyg5T9O3enJdFvUg46Hzd4DUvTvAuEgbdHYXyFsiG4A5sO9IzToMH1cDg==} + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -1795,9 +1999,8 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} @@ -1807,8 +2010,8 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} @@ -1837,8 +2040,13 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.25.5: - resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true @@ -1870,22 +2078,22 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} - expect@30.0.3: - resolution: {integrity: sha512-HXg6NvK35/cSYZCUKAtmlgCFyqKM4frEPbzrav5hRqb0GMz0E0lS5hfzYjSaiaE5ysnp/qI2aeZkeyeIAOeXzQ==} + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - express-rate-limit@7.5.0: - resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==} + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} engines: {node: '>= 16'} peerDependencies: - express: ^4.11 || 5 || ^5.0.0-beta.1 + express: '>= 4.11' - express-session@1.18.1: - resolution: {integrity: sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==} + express-session@1.19.0: + resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==} engines: {node: '>= 0.8.0'} - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} fast-glob@3.3.3: @@ -1895,27 +2103,39 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + 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==} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1939,8 +2159,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - gel@2.1.0: - resolution: {integrity: sha512-HCeRqInCt6BjbMmeghJ6BKeYwOj7WJT5Db6IWWAA3IMUUa7or7zJfTUEkUWCxiOtoXnwnm96sFK9Fr47Yh2hOA==} + gel@2.2.0: + resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==} engines: {node: '>= 18.0.0'} hasBin: true @@ -1975,8 +2195,8 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-tsconfig@4.10.1: - resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -1986,17 +2206,14 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - - globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} + 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 gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} @@ -2005,6 +2222,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -2021,11 +2243,15 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} human-signals@2.1.0: @@ -2036,8 +2262,8 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} ignore-by-default@1.0.1: @@ -2104,9 +2330,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.1: - resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} - engines: {node: '>=16'} + isexe@3.1.2: + resolution: {integrity: sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==} + engines: {node: '>=20'} istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} @@ -2124,28 +2350,23 @@ packages: resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} engines: {node: '>=10'} - istanbul-reports@3.1.7: - resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jake@10.9.2: - resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} - engines: {node: '>=10'} - hasBin: true - - jest-changed-files@30.0.2: - resolution: {integrity: sha512-Ius/iRST9FKfJI+I+kpiDh8JuUlAISnRszF9ixZDIqJF17FckH5sOzKC8a0wd0+D+8em5ADRHA5V5MnfeDk2WA==} + jest-changed-files@30.2.0: + resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-circus@30.0.3: - resolution: {integrity: sha512-rD9qq2V28OASJHJWDRVdhoBdRs6k3u3EmBzDYcyuMby8XCO3Ll1uq9kyqM41ZcC4fMiPulMVh3qMw0cBvDbnyg==} + jest-circus@30.2.0: + resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-cli@30.0.3: - resolution: {integrity: sha512-UWDSj0ayhumEAxpYRlqQLrssEi29kdQ+kddP94AuHhZknrE+mT0cR0J+zMHKFe9XPfX3dKQOc2TfWki3WhFTsA==} + jest-cli@30.2.0: + resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -2154,8 +2375,8 @@ packages: node-notifier: optional: true - jest-config@30.0.3: - resolution: {integrity: sha512-j0L4oRCtJwNyZktXIqwzEiDVQXBbQ4dqXuLD/TZdn++hXIcIfZmjHgrViEy5s/+j4HvITmAXbexVZpQ/jnr0bg==} + jest-config@30.2.0: + resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@types/node': '*' @@ -2169,40 +2390,40 @@ packages: ts-node: optional: true - jest-diff@30.0.3: - resolution: {integrity: sha512-Q1TAV0cUcBTic57SVnk/mug0/ASyAqtSIOkr7RAlxx97llRYsM74+E8N5WdGJUlwCKwgxPAkVjKh653h1+HA9A==} + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-docblock@30.0.1: - resolution: {integrity: sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==} + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-each@30.0.2: - resolution: {integrity: sha512-ZFRsTpe5FUWFQ9cWTMguCaiA6kkW5whccPy9JjD1ezxh+mJeqmz8naL8Fl/oSbNJv3rgB0x87WBIkA5CObIUZQ==} + jest-each@30.2.0: + resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-environment-node@30.0.2: - resolution: {integrity: sha512-XsGtZ0H+a70RsxAQkKuIh0D3ZlASXdZdhpOSBq9WRPq6lhe0IoQHGW0w9ZUaPiZQ/CpkIdprvlfV1QcXcvIQLQ==} + jest-environment-node@30.2.0: + resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-haste-map@30.0.2: - resolution: {integrity: sha512-telJBKpNLeCb4MaX+I5k496556Y2FiKR/QLZc0+MGBYl4k3OO0472drlV2LUe7c1Glng5HuAu+5GLYp//GpdOQ==} + jest-haste-map@30.2.0: + resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-leak-detector@30.0.2: - resolution: {integrity: sha512-U66sRrAYdALq+2qtKffBLDWsQ/XoNNs2Lcr83sc9lvE/hEpNafJlq2lXCPUBMNqamMECNxSIekLfe69qg4KMIQ==} + jest-leak-detector@30.2.0: + resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-matcher-utils@30.0.3: - resolution: {integrity: sha512-hMpVFGFOhYmIIRGJ0HgM9htC5qUiJ00famcc9sRFchJJiLZbbVKrAztcgE6VnXLRxA3XZ0bvNA7hQWh3oHXo/A==} + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-message-util@30.0.2: - resolution: {integrity: sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==} + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-mock@30.0.2: - resolution: {integrity: sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA==} + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-pnp-resolver@1.2.3: @@ -2218,44 +2439,44 @@ packages: resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve-dependencies@30.0.3: - resolution: {integrity: sha512-FlL6u7LiHbF0Oe27k7DHYMq2T2aNpPhxnNo75F7lEtu4A6sSw+TKkNNUGNcVckdFoL0RCWREJsC1HsKDwKRZzQ==} + jest-resolve-dependencies@30.2.0: + resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve@30.0.2: - resolution: {integrity: sha512-q/XT0XQvRemykZsvRopbG6FQUT6/ra+XV6rPijyjT6D0msOyCvR2A5PlWZLd+fH0U8XWKZfDiAgrUNDNX2BkCw==} + jest-resolve@30.2.0: + resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runner@30.0.3: - resolution: {integrity: sha512-CxYBzu9WStOBBXAKkLXGoUtNOWsiS1RRmUQb6SsdUdTcqVncOau7m8AJ4cW3Mz+YL1O9pOGPSYLyvl8HBdFmkQ==} + jest-runner@30.2.0: + resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runtime@30.0.3: - resolution: {integrity: sha512-Xjosq0C48G9XEQOtmgrjXJwPaUPaq3sPJwHDRaiC+5wi4ZWxO6Lx6jNkizK/0JmTulVNuxP8iYwt77LGnfg3/w==} + jest-runtime@30.2.0: + resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-snapshot@30.0.3: - resolution: {integrity: sha512-F05JCohd3OA1N9+5aEPXA6I0qOfZDGIx0zTq5Z4yMBg2i1p5ELfBusjYAWwTkC12c7dHcbyth4QAfQbS7cRjow==} + jest-snapshot@30.2.0: + resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-util@30.0.2: - resolution: {integrity: sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==} + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-validate@30.0.2: - resolution: {integrity: sha512-noOvul+SFER4RIvNAwGn6nmV2fXqBq67j+hKGHKGFCmK4ks/Iy1FSrqQNBLGKlu4ZZIRL6Kg1U72N1nxuRCrGQ==} + jest-validate@30.2.0: + resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-watcher@30.0.2: - resolution: {integrity: sha512-vYO5+E7jJuF+XmONr6CrbXdlYrgvZqtkn6pdkgjt/dU64UAdc0v1cAVaAeWtAfUUMScxNmnUjKPUMdCpNVASwg==} + jest-watcher@30.2.0: + resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-worker@30.0.2: - resolution: {integrity: sha512-RN1eQmx7qSLFA+o9pfJKlqViwL5wt+OL3Vff/A+/cPsmuw7NPwfgl33AP+/agRmHzPOFgXviRycR9kYwlcRQXg==} + jest-worker@30.2.0: + resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest@30.0.3: - resolution: {integrity: sha512-Uy8xfeE/WpT2ZLGDXQmaYNzw2v8NUKuYeKGtkS6sDxwsdQihdgYCXaKIYnph1h95DN5H35ubFDm0dfmsQnjn4Q==} + jest@30.2.0: + resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -2271,8 +2492,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true jsesc@3.1.0: @@ -2288,6 +2509,9 @@ packages: engines: {node: '>=6'} hasBin: true + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -2306,16 +2530,16 @@ packages: lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2325,12 +2549,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - - lru.min@1.1.2: - resolution: {integrity: sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} make-dir@4.0.0: @@ -2393,10 +2613,6 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -2423,28 +2639,28 @@ packages: peerDependencies: cloudinary: ^1.21.0 - multer@2.0.1: - resolution: {integrity: sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==} + multer@2.0.2: + resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} engines: {node: '>= 10.16.0'} - mysql2@3.14.1: - resolution: {integrity: sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==} + mysql2@3.16.3: + resolution: {integrity: sha512-+3XhQEt4FEFuvGV0JjIDj4eP2OT/oIj/54dYvqhblnSzlfcxVOuj+cd15Xz6hsG4HU1a+A5+BA9gm0618C4z7A==} engines: {node: '>= 8.0'} mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - named-placeholders@1.1.3: - resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} - engines: {node: '>=12.0.0'} + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-postinstall@0.2.4: - resolution: {integrity: sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==} + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} hasBin: true @@ -2455,8 +2671,11 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - node-addon-api@8.4.0: - resolution: {integrity: sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} engines: {node: ^18 || ^20 || >= 21} node-gyp-build@4.8.4: @@ -2466,15 +2685,15 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nodemailer@7.0.4: - resolution: {integrity: sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==} + nodemailer@7.0.13: + resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==} engines: {node: '>=6.0.0'} - nodemon@3.1.10: - resolution: {integrity: sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==} + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} engines: {node: '>=10'} hasBin: true @@ -2505,13 +2724,16 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.0.2: - resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -2580,11 +2802,11 @@ packages: pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} - pg-cloudflare@1.2.5: - resolution: {integrity: sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.9.0: - resolution: {integrity: sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==} + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -2594,25 +2816,25 @@ packages: resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} engines: {node: '>=4'} - pg-pool@3.10.0: - resolution: {integrity: sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==} + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} peerDependencies: pg: '>=8.0' - pg-protocol@1.10.0: - resolution: {integrity: sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==} + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg-types@4.0.2: - resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + pg-types@4.1.0: + resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==} engines: {node: '>=10'} - pg@8.16.0: - resolution: {integrity: sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==} - engines: {node: '>= 8.0.0'} + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' peerDependenciesMeta: @@ -2629,8 +2851,8 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} pify@2.3.0: @@ -2651,22 +2873,28 @@ packages: peerDependencies: postcss: ^8.0.0 - postcss-js@4.0.1: - resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: postcss: ^8.4.21 - postcss-load-config@4.0.2: - resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} - engines: {node: '>= 14'} + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} peerDependencies: + jiti: '>=1.21.0' postcss: '>=8.0.9' - ts-node: '>=9.0.0' + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: + jiti: + optional: true postcss: optional: true - ts-node: + tsx: + optional: true + yaml: optional: true postcss-nested@6.2.0: @@ -2682,8 +2910,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.5: - resolution: {integrity: sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -2694,8 +2922,8 @@ packages: resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} engines: {node: '>=12'} - postgres-bytea@1.0.0: - resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} engines: {node: '>=0.10.0'} postgres-bytea@3.0.0: @@ -2721,8 +2949,8 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - pretty-format@30.0.2: - resolution: {integrity: sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==} + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} proxy-addr@2.0.7: @@ -2738,16 +2966,8 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} - q@1.5.1: - resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} - engines: {node: '>=0.6.0', teleport: '>=0.2.0'} - deprecated: |- - You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) - - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -2761,14 +2981,14 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: - react: ^18.3.1 + react: ^19.2.4 react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -2783,8 +3003,8 @@ packages: '@types/react': optional: true - react-remove-scroll@2.7.1: - resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} engines: {node: '>=10'} peerDependencies: '@types/react': '*' @@ -2803,8 +3023,8 @@ packages: '@types/react': optional: true - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -2833,8 +3053,8 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} hasBin: true @@ -2848,30 +3068,34 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true - send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} seq-queue@0.0.5: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} setprototypeof@1.2.0: @@ -2945,12 +3169,15 @@ packages: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} - statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} streamsearch@1.1.0: @@ -2976,8 +3203,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} strip-bom@4.0.0: @@ -2992,8 +3219,8 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - sucrase@3.35.0: - resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true @@ -3013,12 +3240,21 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.8: - resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} + swagger-ui-dist@5.31.0: + resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - tailwindcss@3.4.17: - resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==} + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} hasBin: true @@ -3026,6 +3262,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3033,6 +3272,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -3048,11 +3291,15 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-jest@29.4.0: - resolution: {integrity: sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==} + ts-jest@29.4.6: + resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -3095,8 +3342,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.20.3: - resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} hasBin: true @@ -3124,6 +3371,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + uid-safe@2.1.5: resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} engines: {node: '>= 0.8'} @@ -3138,11 +3390,11 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unrs-resolver@1.9.2: - resolution: {integrity: sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==} + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -3202,6 +3454,17 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3217,8 +3480,8 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - ws@8.18.2: - resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3243,11 +3506,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.8.0: - resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} - engines: {node: '>= 14.6'} - hasBin: true - yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3264,229 +3522,234 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod-validation-error@3.5.0: - resolution: {integrity: sha512-IWK6O51sRkq0YsnYD2oLDuK2BNsIjYUlR0+1YSd4JyBzm6/892IWroUnLc7oW4FU+b0f6948BHi6H8MDcqpOGw==} + zod-validation-error@3.5.4: + resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} engines: {node: '>=18.0.0'} peerDependencies: - zod: ^3.25.0 + zod: ^3.24.4 - zod@3.25.64: - resolution: {integrity: sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: '@alloc/quick-lru@5.2.0': {} - '@ampproject/remapping@2.3.0': + '@babel/code-frame@7.29.0': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.27.7': {} + '@babel/compat-data@7.29.0': {} - '@babel/core@7.27.7': + '@babel/core@7.29.0': dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.27.7) - '@babel/helpers': 7.27.6 - '@babel/parser': 7.27.7 - '@babel/template': 7.27.2 - '@babel/traverse': 7.27.7 - '@babel/types': 7.27.7 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/generator@7.27.5': + '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.27.7 - '@babel/types': 7.27.7 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': + '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.27.7 + '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.1 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-module-imports@7.27.1': + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.27.7 - '@babel/types': 7.27.7 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.27.7)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.27.7 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.27.6': + '@babel/helpers@7.28.6': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.27.7 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 - '@babel/parser@7.27.7': + '@babel/parser@7.29.0': dependencies: - '@babel/types': 7.27.7 + '@babel/types': 7.29.0 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.27.7)': + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.27.7)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.27.7)': + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.27.7)': + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.27.7)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.27.7)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.27.7)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.27.7)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.27.7)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.27.7)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.27.7)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.27.7)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.27.7 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/template@7.27.2': + '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.27.7 - '@babel/types': 7.27.7 + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 - '@babel/traverse@7.27.7': + '@babel/traverse@7.29.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.5 - '@babel/parser': 7.27.7 - '@babel/template': 7.27.2 - '@babel/types': 7.27.7 - debug: 4.4.1(supports-color@5.5.0) - globals: 11.12.0 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/types@7.27.7': + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 '@bcoe/v8-coverage@0.2.3': {} + '@colors/colors@1.6.0': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + '@drizzle-team/brocli@0.10.2': {} - '@emnapi/core@1.4.3': + '@emnapi/core@1.8.1': dependencies: - '@emnapi/wasi-threads': 1.0.2 + '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.4.3': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.0.2': + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 optional: true @@ -3499,12 +3762,15 @@ snapshots: '@esbuild-kit/esm-loader@2.6.5': dependencies: '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.6 '@esbuild/aix-ppc64@0.19.12': optional: true - '@esbuild/aix-ppc64@0.25.5': + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.3': optional: true '@esbuild/android-arm64@0.18.20': @@ -3513,7 +3779,10 @@ snapshots: '@esbuild/android-arm64@0.19.12': optional: true - '@esbuild/android-arm64@0.25.5': + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.3': optional: true '@esbuild/android-arm@0.18.20': @@ -3522,7 +3791,10 @@ snapshots: '@esbuild/android-arm@0.19.12': optional: true - '@esbuild/android-arm@0.25.5': + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.3': optional: true '@esbuild/android-x64@0.18.20': @@ -3531,7 +3803,10 @@ snapshots: '@esbuild/android-x64@0.19.12': optional: true - '@esbuild/android-x64@0.25.5': + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.3': optional: true '@esbuild/darwin-arm64@0.18.20': @@ -3540,7 +3815,10 @@ snapshots: '@esbuild/darwin-arm64@0.19.12': optional: true - '@esbuild/darwin-arm64@0.25.5': + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.3': optional: true '@esbuild/darwin-x64@0.18.20': @@ -3549,7 +3827,10 @@ snapshots: '@esbuild/darwin-x64@0.19.12': optional: true - '@esbuild/darwin-x64@0.25.5': + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.3': optional: true '@esbuild/freebsd-arm64@0.18.20': @@ -3558,7 +3839,10 @@ snapshots: '@esbuild/freebsd-arm64@0.19.12': optional: true - '@esbuild/freebsd-arm64@0.25.5': + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': optional: true '@esbuild/freebsd-x64@0.18.20': @@ -3567,7 +3851,10 @@ snapshots: '@esbuild/freebsd-x64@0.19.12': optional: true - '@esbuild/freebsd-x64@0.25.5': + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.3': optional: true '@esbuild/linux-arm64@0.18.20': @@ -3576,7 +3863,10 @@ snapshots: '@esbuild/linux-arm64@0.19.12': optional: true - '@esbuild/linux-arm64@0.25.5': + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.3': optional: true '@esbuild/linux-arm@0.18.20': @@ -3585,7 +3875,10 @@ snapshots: '@esbuild/linux-arm@0.19.12': optional: true - '@esbuild/linux-arm@0.25.5': + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.3': optional: true '@esbuild/linux-ia32@0.18.20': @@ -3594,7 +3887,10 @@ snapshots: '@esbuild/linux-ia32@0.19.12': optional: true - '@esbuild/linux-ia32@0.25.5': + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.3': optional: true '@esbuild/linux-loong64@0.18.20': @@ -3603,7 +3899,10 @@ snapshots: '@esbuild/linux-loong64@0.19.12': optional: true - '@esbuild/linux-loong64@0.25.5': + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.3': optional: true '@esbuild/linux-mips64el@0.18.20': @@ -3612,7 +3911,10 @@ snapshots: '@esbuild/linux-mips64el@0.19.12': optional: true - '@esbuild/linux-mips64el@0.25.5': + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.3': optional: true '@esbuild/linux-ppc64@0.18.20': @@ -3621,7 +3923,10 @@ snapshots: '@esbuild/linux-ppc64@0.19.12': optional: true - '@esbuild/linux-ppc64@0.25.5': + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.3': optional: true '@esbuild/linux-riscv64@0.18.20': @@ -3630,7 +3935,10 @@ snapshots: '@esbuild/linux-riscv64@0.19.12': optional: true - '@esbuild/linux-riscv64@0.25.5': + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.3': optional: true '@esbuild/linux-s390x@0.18.20': @@ -3639,7 +3947,10 @@ snapshots: '@esbuild/linux-s390x@0.19.12': optional: true - '@esbuild/linux-s390x@0.25.5': + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.3': optional: true '@esbuild/linux-x64@0.18.20': @@ -3648,10 +3959,16 @@ snapshots: '@esbuild/linux-x64@0.19.12': optional: true - '@esbuild/linux-x64@0.25.5': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.25.5': + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-x64@0.18.20': @@ -3660,10 +3977,16 @@ snapshots: '@esbuild/netbsd-x64@0.19.12': optional: true - '@esbuild/netbsd-x64@0.25.5': + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/openbsd-arm64@0.25.5': + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-x64@0.18.20': @@ -3672,7 +3995,16 @@ snapshots: '@esbuild/openbsd-x64@0.19.12': optional: true - '@esbuild/openbsd-x64@0.25.5': + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/sunos-x64@0.18.20': @@ -3681,7 +4013,10 @@ snapshots: '@esbuild/sunos-x64@0.19.12': optional: true - '@esbuild/sunos-x64@0.25.5': + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.3': optional: true '@esbuild/win32-arm64@0.18.20': @@ -3690,7 +4025,10 @@ snapshots: '@esbuild/win32-arm64@0.19.12': optional: true - '@esbuild/win32-arm64@0.25.5': + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.3': optional: true '@esbuild/win32-ia32@0.18.20': @@ -3699,7 +4037,10 @@ snapshots: '@esbuild/win32-ia32@0.19.12': optional: true - '@esbuild/win32-ia32@0.25.5': + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.3': optional: true '@esbuild/win32-x64@0.18.20': @@ -3708,14 +4049,17 @@ snapshots: '@esbuild/win32-x64@0.19.12': optional: true - '@esbuild/win32-x64@0.25.5': + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.3': optional: true '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -3725,49 +4069,49 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.1 + js-yaml: 3.14.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.3': {} - '@jest/console@30.0.2': + '@jest/console@30.2.0': dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 - jest-message-util: 30.0.2 - jest-util: 30.0.2 + jest-message-util: 30.2.0 + jest-util: 30.2.0 slash: 3.0.0 - '@jest/core@30.0.3(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3))': + '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3))': dependencies: - '@jest/console': 30.0.2 + '@jest/console': 30.2.0 '@jest/pattern': 30.0.1 - '@jest/reporters': 30.0.2 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 ansi-escapes: 4.3.2 chalk: 4.1.2 - ci-info: 4.2.0 + ci-info: 4.4.0 exit-x: 0.2.2 graceful-fs: 4.2.11 - jest-changed-files: 30.0.2 - jest-config: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) - jest-haste-map: 30.0.2 - jest-message-util: 30.0.2 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 jest-regex-util: 30.0.1 - jest-resolve: 30.0.2 - jest-resolve-dependencies: 30.0.3 - jest-runner: 30.0.3 - jest-runtime: 30.0.3 - jest-snapshot: 30.0.3 - jest-util: 30.0.2 - jest-validate: 30.0.2 - jest-watcher: 30.0.2 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 micromatch: 4.0.8 - pretty-format: 30.0.2 + pretty-format: 30.2.0 slash: 3.0.0 transitivePeerDependencies: - babel-plugin-macros @@ -3777,41 +4121,41 @@ snapshots: '@jest/diff-sequences@30.0.1': {} - '@jest/environment@30.0.2': + '@jest/environment@30.2.0': dependencies: - '@jest/fake-timers': 30.0.2 - '@jest/types': 30.0.1 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 - jest-mock: 30.0.2 + jest-mock: 30.2.0 - '@jest/expect-utils@30.0.3': + '@jest/expect-utils@30.2.0': dependencies: - '@jest/get-type': 30.0.1 + '@jest/get-type': 30.1.0 - '@jest/expect@30.0.3': + '@jest/expect@30.2.0': dependencies: - expect: 30.0.3 - jest-snapshot: 30.0.3 + expect: 30.2.0 + jest-snapshot: 30.2.0 transitivePeerDependencies: - supports-color - '@jest/fake-timers@30.0.2': + '@jest/fake-timers@30.2.0': dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@sinonjs/fake-timers': 13.0.5 '@types/node': 20.16.11 - jest-message-util: 30.0.2 - jest-mock: 30.0.2 - jest-util: 30.0.2 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 - '@jest/get-type@30.0.1': {} + '@jest/get-type@30.1.0': {} - '@jest/globals@30.0.3': + '@jest/globals@30.2.0': dependencies: - '@jest/environment': 30.0.2 - '@jest/expect': 30.0.3 - '@jest/types': 30.0.1 - jest-mock: 30.0.2 + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/types': 30.2.0 + jest-mock: 30.2.0 transitivePeerDependencies: - supports-color @@ -3820,78 +4164,78 @@ snapshots: '@types/node': 20.16.11 jest-regex-util: 30.0.1 - '@jest/reporters@30.0.2': + '@jest/reporters@30.2.0': dependencies: '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 30.0.2 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 - '@jridgewell/trace-mapping': 0.3.25 + '@jest/console': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 '@types/node': 20.16.11 chalk: 4.1.2 - collect-v8-coverage: 1.0.2 + collect-v8-coverage: 1.0.3 exit-x: 0.2.2 - glob: 10.4.5 + glob: 10.5.0 graceful-fs: 4.2.11 istanbul-lib-coverage: 3.2.2 istanbul-lib-instrument: 6.0.3 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.1.7 - jest-message-util: 30.0.2 - jest-util: 30.0.2 - jest-worker: 30.0.2 + istanbul-reports: 3.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + jest-worker: 30.2.0 slash: 3.0.0 string-length: 4.0.2 v8-to-istanbul: 9.3.0 transitivePeerDependencies: - supports-color - '@jest/schemas@30.0.1': + '@jest/schemas@30.0.5': dependencies: - '@sinclair/typebox': 0.34.37 + '@sinclair/typebox': 0.34.48 - '@jest/snapshot-utils@30.0.1': + '@jest/snapshot-utils@30.2.0': dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 chalk: 4.1.2 graceful-fs: 4.2.11 natural-compare: 1.4.0 '@jest/source-map@30.0.1': dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 callsites: 3.1.0 graceful-fs: 4.2.11 - '@jest/test-result@30.0.2': + '@jest/test-result@30.2.0': dependencies: - '@jest/console': 30.0.2 - '@jest/types': 30.0.1 + '@jest/console': 30.2.0 + '@jest/types': 30.2.0 '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.2 + collect-v8-coverage: 1.0.3 - '@jest/test-sequencer@30.0.2': + '@jest/test-sequencer@30.2.0': dependencies: - '@jest/test-result': 30.0.2 + '@jest/test-result': 30.2.0 graceful-fs: 4.2.11 - jest-haste-map: 30.0.2 + jest-haste-map: 30.2.0 slash: 3.0.0 - '@jest/transform@30.0.2': + '@jest/transform@30.2.0': dependencies: - '@babel/core': 7.27.7 - '@jest/types': 30.0.1 - '@jridgewell/trace-mapping': 0.3.25 - babel-plugin-istanbul: 7.0.0 + '@babel/core': 7.29.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 - jest-haste-map: 30.0.2 + jest-haste-map: 30.2.0 jest-regex-util: 30.0.1 - jest-util: 30.0.2 + jest-util: 30.2.0 micromatch: 4.0.8 pirates: 4.0.7 slash: 3.0.0 @@ -3899,43 +4243,45 @@ snapshots: transitivePeerDependencies: - supports-color - '@jest/types@30.0.1': + '@jest/types@30.2.0': dependencies: '@jest/pattern': 30.0.1 - '@jest/schemas': 30.0.1 + '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 '@types/node': 20.16.11 - '@types/yargs': 17.0.33 + '@types/yargs': 17.0.35 chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.8': + '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/set-array@1.2.1': {} + '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.25': + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/wasm-runtime@0.2.11': + '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.4.3 - '@emnapi/runtime': 1.4.3 - '@tybys/wasm-util': 0.9.0 + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 optional: true '@neondatabase/serverless@0.10.4': @@ -3952,159 +4298,134 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 - '@petamoriken/float16@3.9.2': {} + '@petamoriken/float16@3.9.3': {} '@pkgjs/parseargs@0.11.0': optional: true - '@pkgr/core@0.2.7': {} + '@pkgr/core@0.2.9': {} - '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-compose-refs@1.1.2(react@19.2.4)': dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 + react: 19.2.4 - '@radix-ui/react-context@1.1.2(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-context@1.1.2(react@19.2.4)': dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 - - '@radix-ui/react-dialog@1.1.14(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + react: 19.2.4 + + '@radix-ui/react-dialog@1.1.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-context': 1.1.2(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(react@19.2.4) aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.23 - '@types/react-dom': 18.3.7(@types/react@18.3.23) - - '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.23 - '@types/react-dom': 18.3.7(@types/react@18.3.23) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(react@19.2.4) - '@radix-ui/react-focus-guards@1.1.2(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.1.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-focus-guards@1.1.3(react@19.2.4)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.23 - '@types/react-dom': 18.3.7(@types/react@18.3.23) + react: 19.2.4 - '@radix-ui/react-id@1.1.1(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-focus-scope@1.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-id@1.1.1(react@19.2.4)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.23 - '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 - '@radix-ui/react-presence@1.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-portal@1.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.23 - '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-presence@1.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.23 - '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-slot@1.2.3(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-primitive@2.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 + '@radix-ui/react-slot': 1.2.3(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-primitive@2.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 + '@radix-ui/react-slot': 1.2.4(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-slot@1.2.3(react@19.2.4)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + react: 19.2.4 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-slot@1.2.4(react@19.2.4)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + react: 19.2.4 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-use-callback-ref@1.1.1(react@19.2.4)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 + react: 19.2.4 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.23)(react@18.3.1)': + '@radix-ui/react-use-controllable-state@1.2.2(react@19.2.4)': dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.23 + '@radix-ui/react-use-effect-event': 0.0.2(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 + + '@radix-ui/react-use-effect-event@0.0.2(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.4) + react: 19.2.4 + + '@radix-ui/react-use-escape-keydown@1.1.1(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.4) + react: 19.2.4 + + '@radix-ui/react-use-layout-effect@1.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@scarf/scarf@1.4.0': {} - '@sinclair/typebox@0.34.37': {} + '@sinclair/typebox@0.34.48': {} '@sinonjs/commons@3.0.1': dependencies: @@ -4114,7 +4435,12 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@tsconfig/node10@1.0.11': {} + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -4122,31 +4448,31 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tybys/wasm-util@0.9.0': + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.27.7 - '@babel/types': 7.27.7 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.20.7 + '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.27.7 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.27.7 - '@babel/types': 7.27.7 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 - '@types/babel__traverse@7.20.7': + '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.27.7 + '@babel/types': 7.29.0 '@types/bcrypt@5.0.2': dependencies: @@ -4161,7 +4487,7 @@ snapshots: dependencies: '@types/express': 4.17.21 '@types/express-session': 1.18.2 - '@types/pg': 8.15.4 + '@types/pg': 8.16.0 '@types/connect@3.4.38': dependencies: @@ -4171,12 +4497,12 @@ snapshots: dependencies: '@types/node': 20.16.11 - '@types/express-serve-static-core@4.19.6': + '@types/express-serve-static-core@4.19.8': dependencies: '@types/node': 20.16.11 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 - '@types/send': 0.17.5 + '@types/send': 1.2.1 '@types/express-session@1.18.2': dependencies: @@ -4185,9 +4511,9 @@ snapshots: '@types/express@4.17.21': dependencies: '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.6 + '@types/express-serve-static-core': 4.19.8 '@types/qs': 6.14.0 - '@types/serve-static': 1.15.8 + '@types/serve-static': 2.2.0 '@types/http-errors@2.0.5': {} @@ -4203,10 +4529,8 @@ snapshots: '@types/jest@30.0.0': dependencies: - expect: 30.0.3 - pretty-format: 30.0.2 - - '@types/mime@1.3.5': {} + expect: 30.2.0 + pretty-format: 30.2.0 '@types/multer@1.4.13': dependencies: @@ -4216,7 +4540,7 @@ snapshots: dependencies: undici-types: 6.19.8 - '@types/nodemailer@6.4.17': + '@types/nodemailer@6.4.22': dependencies: '@types/node': 20.16.11 @@ -4238,115 +4562,106 @@ snapshots: '@types/pg@8.11.6': dependencies: '@types/node': 20.16.11 - pg-protocol: 1.10.0 - pg-types: 4.0.2 + pg-protocol: 1.11.0 + pg-types: 4.1.0 - '@types/pg@8.15.4': + '@types/pg@8.16.0': dependencies: '@types/node': 20.16.11 - pg-protocol: 1.10.0 + pg-protocol: 1.11.0 pg-types: 2.2.0 - '@types/prop-types@15.7.15': - optional: true - '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} - '@types/react-dom@18.3.7(@types/react@18.3.23)': - dependencies: - '@types/react': 18.3.23 - optional: true - - '@types/react@18.3.23': + '@types/send@1.2.1': dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.1.3 - optional: true - - '@types/send@0.17.5': - dependencies: - '@types/mime': 1.3.5 '@types/node': 20.16.11 - '@types/serve-static@1.15.8': + '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 '@types/node': 20.16.11 - '@types/send': 0.17.5 '@types/stack-utils@2.0.3': {} + '@types/swagger-ui-express@4.1.8': + dependencies: + '@types/express': 4.17.21 + '@types/serve-static': 2.2.0 + + '@types/triple-beam@1.3.5': {} + '@types/ws@8.18.1': dependencies: '@types/node': 20.16.11 '@types/yargs-parser@21.0.3': {} - '@types/yargs@17.0.33': + '@types/yargs@17.0.35': dependencies: '@types/yargs-parser': 21.0.3 '@ungap/structured-clone@1.3.0': {} - '@unrs/resolver-binding-android-arm-eabi@1.9.2': + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true - '@unrs/resolver-binding-android-arm64@1.9.2': + '@unrs/resolver-binding-android-arm64@1.11.1': optional: true - '@unrs/resolver-binding-darwin-arm64@1.9.2': + '@unrs/resolver-binding-darwin-arm64@1.11.1': optional: true - '@unrs/resolver-binding-darwin-x64@1.9.2': + '@unrs/resolver-binding-darwin-x64@1.11.1': optional: true - '@unrs/resolver-binding-freebsd-x64@1.9.2': + '@unrs/resolver-binding-freebsd-x64@1.11.1': optional: true - '@unrs/resolver-binding-linux-arm-gnueabihf@1.9.2': + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': optional: true - '@unrs/resolver-binding-linux-arm-musleabihf@1.9.2': + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': optional: true - '@unrs/resolver-binding-linux-arm64-gnu@1.9.2': + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-arm64-musl@1.9.2': + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': optional: true - '@unrs/resolver-binding-linux-ppc64-gnu@1.9.2': + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-riscv64-gnu@1.9.2': + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-riscv64-musl@1.9.2': + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': optional: true - '@unrs/resolver-binding-linux-s390x-gnu@1.9.2': + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-x64-gnu@1.9.2': + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': optional: true - '@unrs/resolver-binding-linux-x64-musl@1.9.2': + '@unrs/resolver-binding-linux-x64-musl@1.11.1': optional: true - '@unrs/resolver-binding-wasm32-wasi@1.9.2': + '@unrs/resolver-binding-wasm32-wasi@1.11.1': dependencies: - '@napi-rs/wasm-runtime': 0.2.11 + '@napi-rs/wasm-runtime': 0.2.12 optional: true - '@unrs/resolver-binding-win32-arm64-msvc@1.9.2': + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': optional: true - '@unrs/resolver-binding-win32-ia32-msvc@1.9.2': + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': optional: true - '@unrs/resolver-binding-win32-x64-msvc@1.9.2': + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true accepts@1.3.8: @@ -4366,7 +4681,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.1.0: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: @@ -4374,7 +4689,7 @@ snapshots: ansi-styles@5.2.0: {} - ansi-styles@6.2.1: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -4403,22 +4718,22 @@ snapshots: aws-ssl-profiles@1.1.2: {} - babel-jest@30.0.2(@babel/core@7.27.7): + babel-jest@30.2.0(@babel/core@7.29.0): dependencies: - '@babel/core': 7.27.7 - '@jest/transform': 30.0.2 + '@babel/core': 7.29.0 + '@jest/transform': 30.2.0 '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 7.0.0 - babel-preset-jest: 30.0.1(@babel/core@7.27.7) + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.2.0(@babel/core@7.29.0) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 transitivePeerDependencies: - supports-color - babel-plugin-istanbul@7.0.0: + babel-plugin-istanbul@7.0.1: dependencies: - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 6.0.3 @@ -4426,58 +4741,58 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-jest-hoist@30.0.1: + babel-plugin-jest-hoist@30.2.0: dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.27.7 '@types/babel__core': 7.20.5 - babel-preset-current-node-syntax@1.1.0(@babel/core@7.27.7): - dependencies: - '@babel/core': 7.27.7 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.27.7) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.27.7) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.27.7) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.27.7) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.27.7) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.27.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.27.7) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.7) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.27.7) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.27.7) - - babel-preset-jest@30.0.1(@babel/core@7.27.7): - dependencies: - '@babel/core': 7.27.7 - babel-plugin-jest-hoist: 30.0.1 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.27.7) + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-jest@30.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) balanced-match@1.0.2: {} + baseline-browser-mapping@2.9.19: {} + bcrypt@6.0.0: dependencies: - node-addon-api: 8.4.0 + node-addon-api: 8.5.0 node-gyp-build: 4.8.4 binary-extensions@2.3.0: {} - body-parser@1.20.3: + body-parser@1.20.4: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 2.6.9 depd: 2.0.0 destroy: 1.2.0 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 + qs: 6.14.1 + raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 transitivePeerDependencies: @@ -4496,12 +4811,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.25.1: + browserslist@4.28.1: dependencies: - caniuse-lite: 1.0.30001726 - electron-to-chromium: 1.5.176 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.25.1) + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) bs-logger@0.2.6: dependencies: @@ -4513,7 +4829,7 @@ snapshots: buffer-from@1.1.2: {} - bufferutil@4.0.9: + bufferutil@4.1.0: dependencies: node-gyp-build: 4.8.4 optional: true @@ -4542,7 +4858,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001726: {} + caniuse-lite@1.0.30001769: {} chalk@4.1.2: dependencies: @@ -4563,9 +4879,9 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - ci-info@4.2.0: {} + ci-info@4.4.0: {} - cjs-module-lexer@2.1.0: {} + cjs-module-lexer@2.2.0: {} cliui@8.0.1: dependencies: @@ -4573,33 +4889,47 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - cloudinary@2.7.0: + cloudinary@2.9.0: dependencies: - lodash: 4.17.21 - q: 1.5.1 + lodash: 4.17.23 - cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + cmdk@1.1.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(react@19.2.4) + '@radix-ui/react-primitive': 2.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) transitivePeerDependencies: - '@types/react' - '@types/react-dom' co@4.6.0: {} - collect-v8-coverage@1.0.2: {} + collect-v8-coverage@1.0.3: {} color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + color-name@1.1.4: {} + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + commander@4.1.1: {} concat-map@0.0.1: {} @@ -4613,7 +4943,7 @@ snapshots: connect-pg-simple@10.0.0: dependencies: - pg: 8.16.0 + pg: 8.18.0 transitivePeerDependencies: - pg-native @@ -4625,15 +4955,11 @@ snapshots: convert-source-map@2.0.0: {} - cookie-signature@1.0.6: {} - cookie-signature@1.0.7: {} - cookie@0.7.1: {} - cookie@0.7.2: {} - cors@2.8.5: + cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 @@ -4648,22 +4974,19 @@ snapshots: cssesc@3.0.0: {} - csstype@3.1.3: - optional: true - date-fns@3.6.0: {} debug@2.6.9: dependencies: ms: 2.0.0 - debug@4.4.1(supports-color@5.5.0): + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 5.5.0 - dedent@1.6.0: {} + dedent@1.7.1: {} deepmerge@4.3.1: {} @@ -4679,11 +5002,11 @@ snapshots: didyoumean@1.2.2: {} - diff@4.0.2: {} + diff@4.0.4: {} dlv@1.1.3: {} - dotenv@16.5.0: {} + dotenv@16.6.1: {} drizzle-kit@0.30.6: dependencies: @@ -4691,21 +5014,21 @@ snapshots: '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.19.12 esbuild-register: 3.6.0(esbuild@0.19.12) - gel: 2.1.0 + gel: 2.2.0 transitivePeerDependencies: - supports-color - drizzle-orm@0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.0): + drizzle-orm@0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0): optionalDependencies: '@neondatabase/serverless': 0.10.4 - '@types/pg': 8.15.4 - mysql2: 3.14.1 - pg: 8.16.0 + '@types/pg': 8.16.0 + mysql2: 3.16.3 + pg: 8.18.0 - drizzle-zod@0.7.1(drizzle-orm@0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.0))(zod@3.25.64): + drizzle-zod@0.7.1(drizzle-orm@0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0))(zod@3.25.76): dependencies: - drizzle-orm: 0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.15.4)(mysql2@3.14.1)(pg@8.16.0) - zod: 3.25.64 + drizzle-orm: 0.39.3(@neondatabase/serverless@0.10.4)(@types/pg@8.16.0)(mysql2@3.16.3)(pg@8.18.0) + zod: 3.25.76 dunder-proto@1.0.1: dependencies: @@ -4717,11 +5040,7 @@ snapshots: ee-first@1.1.1: {} - ejs@3.1.10: - dependencies: - jake: 10.9.2 - - electron-to-chromium@1.5.176: {} + electron-to-chromium@1.5.286: {} emittery@0.13.1: {} @@ -4729,13 +5048,13 @@ snapshots: emoji-regex@9.2.2: {} - encodeurl@1.0.2: {} + enabled@2.0.0: {} encodeurl@2.0.0: {} env-paths@3.0.0: {} - error-ex@1.3.2: + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4749,15 +5068,15 @@ snapshots: esbuild-register@3.6.0(esbuild@0.19.12): dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) esbuild: 0.19.12 transitivePeerDependencies: - supports-color - esbuild-register@3.6.0(esbuild@0.25.5): + esbuild-register@3.6.0(esbuild@0.25.12): dependencies: - debug: 4.4.1(supports-color@5.5.0) - esbuild: 0.25.5 + debug: 4.4.3(supports-color@5.5.0) + esbuild: 0.25.12 transitivePeerDependencies: - supports-color optional: true @@ -4813,33 +5132,63 @@ snapshots: '@esbuild/win32-ia32': 0.19.12 '@esbuild/win32-x64': 0.19.12 - esbuild@0.25.5: + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.3: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.5 - '@esbuild/android-arm': 0.25.5 - '@esbuild/android-arm64': 0.25.5 - '@esbuild/android-x64': 0.25.5 - '@esbuild/darwin-arm64': 0.25.5 - '@esbuild/darwin-x64': 0.25.5 - '@esbuild/freebsd-arm64': 0.25.5 - '@esbuild/freebsd-x64': 0.25.5 - '@esbuild/linux-arm': 0.25.5 - '@esbuild/linux-arm64': 0.25.5 - '@esbuild/linux-ia32': 0.25.5 - '@esbuild/linux-loong64': 0.25.5 - '@esbuild/linux-mips64el': 0.25.5 - '@esbuild/linux-ppc64': 0.25.5 - '@esbuild/linux-riscv64': 0.25.5 - '@esbuild/linux-s390x': 0.25.5 - '@esbuild/linux-x64': 0.25.5 - '@esbuild/netbsd-arm64': 0.25.5 - '@esbuild/netbsd-x64': 0.25.5 - '@esbuild/openbsd-arm64': 0.25.5 - '@esbuild/openbsd-x64': 0.25.5 - '@esbuild/sunos-x64': 0.25.5 - '@esbuild/win32-arm64': 0.25.5 - '@esbuild/win32-ia32': 0.25.5 - '@esbuild/win32-x64': 0.25.5 + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 escalade@3.2.0: {} @@ -4865,62 +5214,62 @@ snapshots: exit-x@0.2.2: {} - expect@30.0.3: + expect@30.2.0: dependencies: - '@jest/expect-utils': 30.0.3 - '@jest/get-type': 30.0.1 - jest-matcher-utils: 30.0.3 - jest-message-util: 30.0.2 - jest-mock: 30.0.2 - jest-util: 30.0.2 + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 - express-rate-limit@7.5.0(express@4.21.2): + express-rate-limit@7.5.1(express@4.22.1): dependencies: - express: 4.21.2 + express: 4.22.1 - express-session@1.18.1: + express-session@1.19.0: dependencies: cookie: 0.7.2 cookie-signature: 1.0.7 debug: 2.6.9 depd: 2.0.0 - on-headers: 1.0.2 + on-headers: 1.1.0 parseurl: 1.3.3 safe-buffer: 5.2.1 uid-safe: 2.1.5 transitivePeerDependencies: - supports-color - express@4.21.2: + express@4.22.1: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.3 + body-parser: 1.20.4 content-disposition: 0.5.4 content-type: 1.0.5 - cookie: 0.7.1 - cookie-signature: 1.0.6 + cookie: 0.7.2 + cookie-signature: 1.0.7 debug: 2.6.9 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.1 + finalhandler: 1.3.2 fresh: 0.5.2 - http-errors: 2.0.0 + http-errors: 2.0.1 merge-descriptors: 1.0.3 methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.14.1 range-parser: 1.2.1 safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 + send: 0.19.2 + serve-static: 1.16.3 setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 type-is: 1.6.18 utils-merge: 1.0.1 vary: 1.1.2 @@ -4937,7 +5286,7 @@ snapshots: fast-json-stable-stringify@2.1.0: {} - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4945,22 +5294,24 @@ snapshots: dependencies: bser: 2.1.1 - filelist@1.0.4: - dependencies: - minimatch: 5.1.6 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fecha@4.2.3: {} fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.1: + finalhandler@1.3.2: dependencies: debug: 2.6.9 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 unpipe: 1.0.0 transitivePeerDependencies: - supports-color @@ -4970,6 +5321,8 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + fn.name@1.1.0: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -4986,12 +5339,12 @@ snapshots: function-bind@1.1.2: {} - gel@2.1.0: + gel@2.2.0: dependencies: - '@petamoriken/float16': 3.9.2 - debug: 4.4.1(supports-color@5.5.0) + '@petamoriken/float16': 3.9.3 + debug: 4.4.3(supports-color@5.5.0) env-paths: 3.0.0 - semver: 7.7.2 + semver: 7.7.4 shell-quote: 1.8.3 which: 4.0.0 transitivePeerDependencies: @@ -5029,7 +5382,7 @@ snapshots: get-stream@6.0.1: {} - get-tsconfig@4.10.1: + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -5041,7 +5394,7 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.4.5: + glob@10.5.0: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 @@ -5059,12 +5412,19 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 - globals@11.12.0: {} - gopd@1.2.0: {} graceful-fs@4.2.11: {} + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -5075,14 +5435,16 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + html-escaper@2.0.2: {} - http-errors@2.0.0: + http-errors@2.0.1: dependencies: depd: 2.0.0 inherits: 2.0.4 setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 toidentifier: 1.0.1 human-signals@2.1.0: {} @@ -5091,7 +5453,7 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.6.3: + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5141,17 +5503,17 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.1: {} + isexe@3.1.2: {} istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@6.0.3: dependencies: - '@babel/core': 7.27.7 - '@babel/parser': 7.27.7 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.2 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -5163,13 +5525,13 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.1(supports-color@5.5.0) + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color - istanbul-reports@3.1.7: + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 @@ -5180,38 +5542,31 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jake@10.9.2: - dependencies: - async: 3.2.6 - chalk: 4.1.2 - filelist: 1.0.4 - minimatch: 3.1.2 - - jest-changed-files@30.0.2: + jest-changed-files@30.2.0: dependencies: execa: 5.1.1 - jest-util: 30.0.2 + jest-util: 30.2.0 p-limit: 3.1.0 - jest-circus@30.0.3: + jest-circus@30.2.0: dependencies: - '@jest/environment': 30.0.2 - '@jest/expect': 30.0.3 - '@jest/test-result': 30.0.2 - '@jest/types': 30.0.1 + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 co: 4.6.0 - dedent: 1.6.0 + dedent: 1.7.1 is-generator-fn: 2.1.0 - jest-each: 30.0.2 - jest-matcher-utils: 30.0.3 - jest-message-util: 30.0.2 - jest-runtime: 30.0.3 - jest-snapshot: 30.0.3 - jest-util: 30.0.2 + jest-each: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 p-limit: 3.1.0 - pretty-format: 30.0.2 + pretty-format: 30.2.0 pure-rand: 7.0.1 slash: 3.0.0 stack-utils: 2.0.6 @@ -5219,17 +5574,17 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): + jest-cli@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): dependencies: - '@jest/core': 30.0.3(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) - '@jest/test-result': 30.0.2 - '@jest/types': 30.0.1 + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) - jest-util: 30.0.2 - jest-validate: 30.0.2 + jest-config: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + jest-util: 30.2.0 + jest-validate: 30.2.0 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' @@ -5238,261 +5593,261 @@ snapshots: - supports-color - ts-node - jest-config@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): + jest-config@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): dependencies: - '@babel/core': 7.27.7 - '@jest/get-type': 30.0.1 + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.0.2 - '@jest/types': 30.0.1 - babel-jest: 30.0.2(@babel/core@7.27.7) + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.29.0) chalk: 4.1.2 - ci-info: 4.2.0 + ci-info: 4.4.0 deepmerge: 4.3.1 - glob: 10.4.5 + glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.0.3 - jest-docblock: 30.0.1 - jest-environment-node: 30.0.2 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 jest-regex-util: 30.0.1 - jest-resolve: 30.0.2 - jest-runner: 30.0.3 - jest-util: 30.0.2 - jest-validate: 30.0.2 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 micromatch: 4.0.8 parse-json: 5.2.0 - pretty-format: 30.0.2 + pretty-format: 30.2.0 slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.16.11 - esbuild-register: 3.6.0(esbuild@0.25.5) + esbuild-register: 3.6.0(esbuild@0.25.12) ts-node: 10.9.2(@types/node@20.16.11)(typescript@5.6.3) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-diff@30.0.3: + jest-diff@30.2.0: dependencies: '@jest/diff-sequences': 30.0.1 - '@jest/get-type': 30.0.1 + '@jest/get-type': 30.1.0 chalk: 4.1.2 - pretty-format: 30.0.2 + pretty-format: 30.2.0 - jest-docblock@30.0.1: + jest-docblock@30.2.0: dependencies: detect-newline: 3.1.0 - jest-each@30.0.2: + jest-each@30.2.0: dependencies: - '@jest/get-type': 30.0.1 - '@jest/types': 30.0.1 + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 chalk: 4.1.2 - jest-util: 30.0.2 - pretty-format: 30.0.2 + jest-util: 30.2.0 + pretty-format: 30.2.0 - jest-environment-node@30.0.2: + jest-environment-node@30.2.0: dependencies: - '@jest/environment': 30.0.2 - '@jest/fake-timers': 30.0.2 - '@jest/types': 30.0.1 + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 - jest-mock: 30.0.2 - jest-util: 30.0.2 - jest-validate: 30.0.2 + jest-mock: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 - jest-haste-map@30.0.2: + jest-haste-map@30.2.0: dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@types/node': 20.16.11 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 jest-regex-util: 30.0.1 - jest-util: 30.0.2 - jest-worker: 30.0.2 + jest-util: 30.2.0 + jest-worker: 30.2.0 micromatch: 4.0.8 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 - jest-leak-detector@30.0.2: + jest-leak-detector@30.2.0: dependencies: - '@jest/get-type': 30.0.1 - pretty-format: 30.0.2 + '@jest/get-type': 30.1.0 + pretty-format: 30.2.0 - jest-matcher-utils@30.0.3: + jest-matcher-utils@30.2.0: dependencies: - '@jest/get-type': 30.0.1 + '@jest/get-type': 30.1.0 chalk: 4.1.2 - jest-diff: 30.0.3 - pretty-format: 30.0.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 - jest-message-util@30.0.2: + jest-message-util@30.2.0: dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 30.0.1 + '@babel/code-frame': 7.29.0 + '@jest/types': 30.2.0 '@types/stack-utils': 2.0.3 chalk: 4.1.2 graceful-fs: 4.2.11 micromatch: 4.0.8 - pretty-format: 30.0.2 + pretty-format: 30.2.0 slash: 3.0.0 stack-utils: 2.0.6 - jest-mock@30.0.2: + jest-mock@30.2.0: dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@types/node': 20.16.11 - jest-util: 30.0.2 + jest-util: 30.2.0 - jest-pnp-resolver@1.2.3(jest-resolve@30.0.2): + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): optionalDependencies: - jest-resolve: 30.0.2 + jest-resolve: 30.2.0 jest-regex-util@30.0.1: {} - jest-resolve-dependencies@30.0.3: + jest-resolve-dependencies@30.2.0: dependencies: jest-regex-util: 30.0.1 - jest-snapshot: 30.0.3 + jest-snapshot: 30.2.0 transitivePeerDependencies: - supports-color - jest-resolve@30.0.2: + jest-resolve@30.2.0: dependencies: chalk: 4.1.2 graceful-fs: 4.2.11 - jest-haste-map: 30.0.2 - jest-pnp-resolver: 1.2.3(jest-resolve@30.0.2) - jest-util: 30.0.2 - jest-validate: 30.0.2 + jest-haste-map: 30.2.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.2.0) + jest-util: 30.2.0 + jest-validate: 30.2.0 slash: 3.0.0 - unrs-resolver: 1.9.2 + unrs-resolver: 1.11.1 - jest-runner@30.0.3: + jest-runner@30.2.0: dependencies: - '@jest/console': 30.0.2 - '@jest/environment': 30.0.2 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 + '@jest/console': 30.2.0 + '@jest/environment': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 graceful-fs: 4.2.11 - jest-docblock: 30.0.1 - jest-environment-node: 30.0.2 - jest-haste-map: 30.0.2 - jest-leak-detector: 30.0.2 - jest-message-util: 30.0.2 - jest-resolve: 30.0.2 - jest-runtime: 30.0.3 - jest-util: 30.0.2 - jest-watcher: 30.0.2 - jest-worker: 30.0.2 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-haste-map: 30.2.0 + jest-leak-detector: 30.2.0 + jest-message-util: 30.2.0 + jest-resolve: 30.2.0 + jest-runtime: 30.2.0 + jest-util: 30.2.0 + jest-watcher: 30.2.0 + jest-worker: 30.2.0 p-limit: 3.1.0 source-map-support: 0.5.13 transitivePeerDependencies: - supports-color - jest-runtime@30.0.3: + jest-runtime@30.2.0: dependencies: - '@jest/environment': 30.0.2 - '@jest/fake-timers': 30.0.2 - '@jest/globals': 30.0.3 + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/globals': 30.2.0 '@jest/source-map': 30.0.1 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 - cjs-module-lexer: 2.1.0 - collect-v8-coverage: 1.0.2 - glob: 10.4.5 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 graceful-fs: 4.2.11 - jest-haste-map: 30.0.2 - jest-message-util: 30.0.2 - jest-mock: 30.0.2 + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 jest-regex-util: 30.0.1 - jest-resolve: 30.0.2 - jest-snapshot: 30.0.3 - jest-util: 30.0.2 + jest-resolve: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: - supports-color - jest-snapshot@30.0.3: - dependencies: - '@babel/core': 7.27.7 - '@babel/generator': 7.27.5 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.7) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.7) - '@babel/types': 7.27.7 - '@jest/expect-utils': 30.0.3 - '@jest/get-type': 30.0.1 - '@jest/snapshot-utils': 30.0.1 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.27.7) + jest-snapshot@30.2.0: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) chalk: 4.1.2 - expect: 30.0.3 + expect: 30.2.0 graceful-fs: 4.2.11 - jest-diff: 30.0.3 - jest-matcher-utils: 30.0.3 - jest-message-util: 30.0.2 - jest-util: 30.0.2 - pretty-format: 30.0.2 - semver: 7.7.2 - synckit: 0.11.8 + jest-diff: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + pretty-format: 30.2.0 + semver: 7.7.4 + synckit: 0.11.12 transitivePeerDependencies: - supports-color - jest-util@30.0.2: + jest-util@30.2.0: dependencies: - '@jest/types': 30.0.1 + '@jest/types': 30.2.0 '@types/node': 20.16.11 chalk: 4.1.2 - ci-info: 4.2.0 + ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.2 + picomatch: 4.0.3 - jest-validate@30.0.2: + jest-validate@30.2.0: dependencies: - '@jest/get-type': 30.0.1 - '@jest/types': 30.0.1 + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 camelcase: 6.3.0 chalk: 4.1.2 leven: 3.1.0 - pretty-format: 30.0.2 + pretty-format: 30.2.0 - jest-watcher@30.0.2: + jest-watcher@30.2.0: dependencies: - '@jest/test-result': 30.0.2 - '@jest/types': 30.0.1 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.16.11 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 - jest-util: 30.0.2 + jest-util: 30.2.0 string-length: 4.0.2 - jest-worker@30.0.2: + jest-worker@30.2.0: dependencies: '@types/node': 20.16.11 '@ungap/structured-clone': 1.3.0 - jest-util: 30.0.2 + jest-util: 30.2.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): + jest@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): dependencies: - '@jest/core': 30.0.3(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) - '@jest/types': 30.0.1 + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + '@jest/types': 30.2.0 import-local: 3.2.0 - jest-cli: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + jest-cli: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -5504,7 +5859,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.1: + js-yaml@3.14.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 @@ -5515,6 +5870,8 @@ snapshots: json5@2.2.3: {} + kuler@2.0.0: {} + leven@3.1.0: {} lilconfig@3.1.3: {} @@ -5527,13 +5884,18 @@ snapshots: lodash.memoize@4.1.2: {} - lodash@4.17.21: {} + lodash@4.17.23: {} - long@5.3.2: {} - - loose-envify@1.4.0: + logform@2.7.0: dependencies: - js-tokens: 4.0.0 + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + long@5.3.2: {} lru-cache@10.4.3: {} @@ -5546,13 +5908,11 @@ snapshots: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} - - lru.min@1.1.2: {} + lru.min@1.1.4: {} make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 make-error@1.3.6: {} @@ -5566,7 +5926,7 @@ snapshots: memorystore@1.6.7: dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) lru-cache: 4.1.5 transitivePeerDependencies: - supports-color @@ -5598,10 +5958,6 @@ snapshots: dependencies: brace-expansion: 1.1.12 - minimatch@5.1.6: - dependencies: - brace-expansion: 2.0.2 - minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -5618,11 +5974,11 @@ snapshots: ms@2.1.3: {} - multer-storage-cloudinary@4.0.0(cloudinary@2.7.0): + multer-storage-cloudinary@4.0.0(cloudinary@2.9.0): dependencies: - cloudinary: 2.7.0 + cloudinary: 2.9.0 - multer@2.0.1: + multer@2.0.2: dependencies: append-field: 1.0.0 busboy: 1.6.0 @@ -5632,15 +5988,15 @@ snapshots: type-is: 1.6.18 xtend: 4.0.2 - mysql2@3.14.1: + mysql2@3.16.3: dependencies: aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 - iconv-lite: 0.6.3 + iconv-lite: 0.7.2 long: 5.3.2 - lru.min: 1.1.2 - named-placeholders: 1.1.3 + lru.min: 1.1.4 + named-placeholders: 1.1.6 seq-queue: 0.0.5 sqlstring: 2.3.3 @@ -5650,36 +6006,38 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - named-placeholders@1.1.3: + named-placeholders@1.1.6: dependencies: - lru-cache: 7.18.3 + lru.min: 1.1.4 nanoid@3.3.11: {} - napi-postinstall@0.2.4: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} negotiator@0.6.3: {} - node-addon-api@8.4.0: {} + neo-async@2.6.2: {} + + node-addon-api@8.5.0: {} node-gyp-build@4.8.4: {} node-int64@0.4.0: {} - node-releases@2.0.19: {} + node-releases@2.0.27: {} - nodemailer@7.0.4: {} + nodemailer@7.0.13: {} - nodemon@3.1.10: + nodemon@3.1.11: dependencies: chokidar: 3.6.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) ignore-by-default: 1.0.1 minimatch: 3.1.2 pstree.remy: 1.1.8 - semver: 7.7.2 + semver: 7.7.4 simple-update-notifier: 2.0.0 supports-color: 5.5.0 touch: 3.1.1 @@ -5703,12 +6061,16 @@ snapshots: dependencies: ee-first: 1.1.1 - on-headers@1.0.2: {} + on-headers@1.1.0: {} once@1.4.0: dependencies: wrappy: 1.0.2 + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -5731,8 +6093,8 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.2 + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -5767,30 +6129,30 @@ snapshots: pause@0.0.1: {} - pg-cloudflare@1.2.5: + pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.9.0: {} + pg-connection-string@2.11.0: {} pg-int8@1.0.1: {} pg-numeric@1.0.2: {} - pg-pool@3.10.0(pg@8.16.0): + pg-pool@3.11.0(pg@8.18.0): dependencies: - pg: 8.16.0 + pg: 8.18.0 - pg-protocol@1.10.0: {} + pg-protocol@1.11.0: {} pg-types@2.2.0: dependencies: pg-int8: 1.0.1 postgres-array: 2.0.0 - postgres-bytea: 1.0.0 + postgres-bytea: 1.0.1 postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg-types@4.0.2: + pg-types@4.1.0: dependencies: pg-int8: 1.0.1 pg-numeric: 1.0.2 @@ -5800,15 +6162,15 @@ snapshots: postgres-interval: 3.0.0 postgres-range: 1.1.4 - pg@8.16.0: + pg@8.18.0: dependencies: - pg-connection-string: 2.9.0 - pg-pool: 3.10.0(pg@8.16.0) - pg-protocol: 1.10.0 + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: - pg-cloudflare: 1.2.5 + pg-cloudflare: 1.3.0 pgpass@1.0.5: dependencies: @@ -5818,7 +6180,7 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.2: {} + picomatch@4.0.3: {} pify@2.3.0: {} @@ -5828,29 +6190,29 @@ snapshots: dependencies: find-up: 4.1.0 - postcss-import@15.1.0(postcss@8.5.5): + postcss-import@15.1.0(postcss@8.5.6): dependencies: - postcss: 8.5.5 + postcss: 8.5.6 postcss-value-parser: 4.2.0 read-cache: 1.0.0 - resolve: 1.22.10 + resolve: 1.22.11 - postcss-js@4.0.1(postcss@8.5.5): + postcss-js@4.1.0(postcss@8.5.6): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.5 + postcss: 8.5.6 - postcss-load-config@4.0.2(postcss@8.5.5)(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 - yaml: 2.8.0 optionalDependencies: - postcss: 8.5.5 - ts-node: 10.9.2(@types/node@20.16.11)(typescript@5.6.3) + jiti: 1.21.7 + postcss: 8.5.6 + tsx: 4.21.0 - postcss-nested@6.2.0(postcss@8.5.5): + postcss-nested@6.2.0(postcss@8.5.6): dependencies: - postcss: 8.5.5 + postcss: 8.5.6 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.1.2: @@ -5860,7 +6222,7 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.5: + postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -5870,7 +6232,7 @@ snapshots: postgres-array@3.0.4: {} - postgres-bytea@1.0.0: {} + postgres-bytea@1.0.1: {} postgres-bytea@3.0.0: dependencies: @@ -5888,9 +6250,9 @@ snapshots: postgres-range@1.1.4: {} - pretty-format@30.0.2: + pretty-format@30.2.0: dependencies: - '@jest/schemas': 30.0.1 + '@jest/schemas': 30.0.5 ansi-styles: 5.2.0 react-is: 18.3.1 @@ -5905,9 +6267,7 @@ snapshots: pure-rand@7.0.1: {} - q@1.5.1: {} - - qs@6.13.0: + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -5917,51 +6277,42 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.2: + raw-body@2.5.3: dependencies: bytes: 3.1.2 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 unpipe: 1.0.0 - react-dom@18.3.1(react@18.3.1): + react-dom@19.2.4(react@19.2.4): dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 + react: 19.2.4 + scheduler: 0.27.0 react-is@18.3.1: {} - react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1): + react-remove-scroll-bar@2.3.8(react@19.2.4): dependencies: - react: 18.3.1 - react-style-singleton: 2.2.3(@types/react@18.3.23)(react@18.3.1) + react: 19.2.4 + react-style-singleton: 2.2.3(react@19.2.4) tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.23 - react-remove-scroll@2.7.1(@types/react@18.3.23)(react@18.3.1): + react-remove-scroll@2.7.2(react@19.2.4): dependencies: - react: 18.3.1 - react-remove-scroll-bar: 2.3.8(@types/react@18.3.23)(react@18.3.1) - react-style-singleton: 2.2.3(@types/react@18.3.23)(react@18.3.1) + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(react@19.2.4) + react-style-singleton: 2.2.3(react@19.2.4) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@18.3.23)(react@18.3.1) - use-sidecar: 1.1.3(@types/react@18.3.23)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.23 + use-callback-ref: 1.3.3(react@19.2.4) + use-sidecar: 1.1.3(react@19.2.4) - react-style-singleton@2.2.3(@types/react@18.3.23)(react@18.3.1): + react-style-singleton@2.2.3(react@19.2.4): dependencies: get-nonce: 1.0.1 - react: 18.3.1 + react: 19.2.4 tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.23 - react@18.3.1: - dependencies: - loose-envify: 1.4.0 + react@19.2.4: {} read-cache@1.0.0: dependencies: @@ -5987,7 +6338,7 @@ snapshots: resolve-pkg-maps@1.0.0: {} - resolve@1.22.10: + resolve@1.22.11: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 @@ -6001,42 +6352,42 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 + scheduler@0.27.0: {} semver@6.3.1: {} - semver@7.7.2: {} + semver@7.7.4: {} - send@0.19.0: + send@0.19.2: dependencies: debug: 2.6.9 depd: 2.0.0 destroy: 1.2.0 - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 fresh: 0.5.2 - http-errors: 2.0.0 + http-errors: 2.0.1 mime: 1.6.0 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color seq-queue@0.0.5: {} - serve-static@1.16.2: + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.0 + send: 0.19.2 transitivePeerDependencies: - supports-color @@ -6084,7 +6435,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 slash@3.0.0: {} @@ -6108,11 +6459,13 @@ snapshots: sqlstring@2.3.3: {} + stack-trace@0.0.10: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 - statuses@2.0.1: {} + statuses@2.0.2: {} streamsearch@1.1.0: {} @@ -6131,7 +6484,7 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 string_decoder@1.3.0: dependencies: @@ -6141,9 +6494,9 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.0: + strip-ansi@7.1.2: dependencies: - ansi-regex: 6.1.0 + ansi-regex: 6.2.2 strip-bom@4.0.0: {} @@ -6151,14 +6504,14 @@ snapshots: strip-json-comments@3.1.1: {} - sucrase@3.35.0: + sucrase@3.35.1: dependencies: - '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 - glob: 10.4.5 lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.7 + tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 supports-color@5.5.0: @@ -6175,11 +6528,20 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.8: + swagger-ui-dist@5.31.0: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@4.22.1): dependencies: - '@pkgr/core': 0.2.7 + express: 4.22.1 + swagger-ui-dist: 5.31.0 - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)): + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tailwindcss@3.4.19(tsx@4.21.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -6195,16 +6557,17 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.5 - postcss-import: 15.1.0(postcss@8.5.5) - postcss-js: 4.0.1(postcss@8.5.5) - postcss-load-config: 4.0.2(postcss@8.5.5)(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) - postcss-nested: 6.2.0(postcss@8.5.5) + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.1.0(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0) + postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 - resolve: 1.22.10 - sucrase: 3.35.0 + resolve: 1.22.11 + sucrase: 3.35.1 transitivePeerDependencies: - - ts-node + - tsx + - yaml test-exclude@6.0.0: dependencies: @@ -6212,6 +6575,8 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + text-hex@1.0.0: {} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -6220,6 +6585,11 @@ snapshots: dependencies: any-promise: 1.3.0 + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -6230,33 +6600,35 @@ snapshots: touch@3.1.1: {} + triple-beam@1.4.1: {} + ts-interface-checker@0.1.13: {} - ts-jest@29.4.0(@babel/core@7.27.7)(@jest/transform@30.0.2)(@jest/types@30.0.1)(babel-jest@30.0.2(@babel/core@7.27.7))(esbuild@0.25.5)(jest-util@30.0.2)(jest@30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)))(typescript@5.6.3): + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.25.12)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)))(typescript@5.6.3): dependencies: bs-logger: 0.2.6 - ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 30.0.3(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) + handlebars: 4.7.8 + jest: 30.2.0(@types/node@20.16.11)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.7.2 + semver: 7.7.4 type-fest: 4.41.0 typescript: 5.6.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.27.7 - '@jest/transform': 30.0.2 - '@jest/types': 30.0.1 - babel-jest: 30.0.2(@babel/core@7.27.7) - esbuild: 0.25.5 - jest-util: 30.0.2 + '@babel/core': 7.29.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.29.0) + esbuild: 0.25.12 + jest-util: 30.2.0 ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 + '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 @@ -6265,7 +6637,7 @@ snapshots: acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 - diff: 4.0.2 + diff: 4.0.4 make-error: 1.3.6 typescript: 5.6.3 v8-compile-cache-lib: 3.0.1 @@ -6273,10 +6645,10 @@ snapshots: tslib@2.8.1: {} - tsx@4.20.3: + tsx@4.21.0: dependencies: - esbuild: 0.25.5 - get-tsconfig: 4.10.1 + esbuild: 0.27.3 + get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 @@ -6295,6 +6667,9 @@ snapshots: typescript@5.6.3: {} + uglify-js@3.19.3: + optional: true + uid-safe@2.1.5: dependencies: random-bytes: 1.0.0 @@ -6305,50 +6680,46 @@ snapshots: unpipe@1.0.0: {} - unrs-resolver@1.9.2: + unrs-resolver@1.11.1: dependencies: - napi-postinstall: 0.2.4 + napi-postinstall: 0.3.4 optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.9.2 - '@unrs/resolver-binding-android-arm64': 1.9.2 - '@unrs/resolver-binding-darwin-arm64': 1.9.2 - '@unrs/resolver-binding-darwin-x64': 1.9.2 - '@unrs/resolver-binding-freebsd-x64': 1.9.2 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.9.2 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.9.2 - '@unrs/resolver-binding-linux-arm64-gnu': 1.9.2 - '@unrs/resolver-binding-linux-arm64-musl': 1.9.2 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.9.2 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.9.2 - '@unrs/resolver-binding-linux-riscv64-musl': 1.9.2 - '@unrs/resolver-binding-linux-s390x-gnu': 1.9.2 - '@unrs/resolver-binding-linux-x64-gnu': 1.9.2 - '@unrs/resolver-binding-linux-x64-musl': 1.9.2 - '@unrs/resolver-binding-wasm32-wasi': 1.9.2 - '@unrs/resolver-binding-win32-arm64-msvc': 1.9.2 - '@unrs/resolver-binding-win32-ia32-msvc': 1.9.2 - '@unrs/resolver-binding-win32-x64-msvc': 1.9.2 - - update-browserslist-db@1.1.3(browserslist@4.25.1): - dependencies: - browserslist: 4.25.1 + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 - use-callback-ref@1.3.3(@types/react@18.3.23)(react@18.3.1): + use-callback-ref@1.3.3(react@19.2.4): dependencies: - react: 18.3.1 + react: 19.2.4 tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.23 - use-sidecar@1.1.3(@types/react@18.3.23)(react@18.3.1): + use-sidecar@1.1.3(react@19.2.4): dependencies: detect-node-es: 1.1.0 - react: 18.3.1 + react: 19.2.4 tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.23 util-deprecate@1.0.2: {} @@ -6360,7 +6731,7 @@ snapshots: v8-to-istanbul@9.3.0: dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 @@ -6376,7 +6747,29 @@ snapshots: which@4.0.0: dependencies: - isexe: 3.1.1 + isexe: 3.1.2 + + 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 + + wordwrap@1.0.0: {} wrap-ansi@7.0.0: dependencies: @@ -6386,9 +6779,9 @@ snapshots: wrap-ansi@8.1.0: dependencies: - ansi-styles: 6.2.1 + ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 wrappy@1.0.2: {} @@ -6397,9 +6790,9 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 - ws@8.18.2(bufferutil@4.0.9): + ws@8.19.0(bufferutil@4.1.0): optionalDependencies: - bufferutil: 4.0.9 + bufferutil: 4.1.0 xtend@4.0.2: {} @@ -6409,8 +6802,6 @@ snapshots: yallist@3.1.1: {} - yaml@2.8.0: {} - yargs-parser@21.1.1: {} yargs@17.7.2: @@ -6427,8 +6818,8 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@3.5.0(zod@3.25.64): + zod-validation-error@3.5.4(zod@3.25.76): dependencies: - zod: 3.25.64 + zod: 3.25.76 - zod@3.25.64: {} + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..9316573 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "." + diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..e7bb30a --- /dev/null +++ b/render.yaml @@ -0,0 +1,16 @@ +# Render Blueprint – build and runtime in sync with Render +# Set DATABASE_URL, SESSION_SECRET, and other secrets in Render Dashboard β†’ Environment + +services: + - type: web + name: library-management-api + runtime: node + + buildCommand: pnpm install && pnpm run build + startCommand: pnpm start + + healthCheckPath: /api/v1/health + + envVars: + - key: NODE_ENV + value: production diff --git a/server/routes.ts b/server/routes.ts deleted file mode 100644 index 2b6685d..0000000 --- a/server/routes.ts +++ /dev/null @@ -1,1636 +0,0 @@ -import type { Express, Request, Response, NextFunction } from "express"; -import { createServer, type Server } from "http"; -import drizzleService from "../services/drizzle-services"; -import { compare } from "bcrypt"; - -import { logEvents } from "../middlewares/logger"; -import bcrypt from "bcrypt"; -import { sendResponseEmail } from "../services/email-service"; -import { Story } from "../config/database/schema"; -import { cloudinaryService } from "../config/bucket-storage/cloudinary"; -import multer from 'multer'; - -import { - generalApiLimiter, - authLimiter, - contactLimiter, - uploadLimiter, - adminLimiter, - publicLimiter, - emailLimiter, - searchLimiter -} from '../middlewares/rate-limiters'; - -// Configure multer for memory storage -const upload = multer({ - storage: multer.memoryStorage(), - limits: { - fileSize: 10 * 1024 * 1024, // 10MB limit - }, - fileFilter: (req, file, cb) => { - // Allow only image files - if (file.mimetype.startsWith('image/')) { - cb(null, true); - } else { - cb(new Error('Only image files are allowed')); - } - } -}); - -// Helper function to upload image to Cloudinary -async function uploadImageToCloudinary(file: Express.Multer.File, folder: string): Promise { - if (!cloudinaryService.isReady()) { - throw new Error('Cloudinary not configured'); - } - - try { - const base64Image = `data:${file.mimetype};base64,${file.buffer.toString('base64')}`; - const result = await cloudinaryService.uploadImage(base64Image, { - folder: `library-platform/${folder}`, - }); - return result.url; - } catch (error) { - console.error("Cloudinary upload error:", error); - throw error; - } -} - -// API wrapper to ensure JSON responses -function apiHandler(handler: (req: Request, res: Response) => Promise) { - return async (req: Request, res: Response, next: NextFunction) => { - // Always set JSON content type - res.setHeader('Content-Type', 'application/json'); - - try { - await handler(req, res); - } catch (error) { - console.error("API Error:", error); - res.status(500).json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error) - }); - } - }; -} - -// Create a middleware to ensure all API responses are JSON -const jsonApiMiddleware = (req: Request, res: Response, next: NextFunction) => { - // Set the content type before any response is sent - res.setHeader('Content-Type', 'application/json'); - - // Store the original res.send method - const originalSend = res.send; - - // Override the send method to always ensure proper JSON responses - res.send = function (body: any) { - try { - // If body is already a string but not JSON formatted, convert it to a JSON response - if (typeof body === 'string' && (!body.startsWith('{') && !body.startsWith('['))) { - return originalSend.call(this, JSON.stringify({ message: body })); - } - return originalSend.call(this, body); - } catch (error) { - console.error("Error in JSON middleware:", error); - return originalSend.call(this, JSON.stringify({ error: "Internal server error" })); - } - }; - - next(); -}; - -export async function registerRoutes(global_path: string, app: Express): Promise { - // Apply JSON API middleware to all API routes - app.use(global_path, jsonApiMiddleware); - app.use(global_path, generalApiLimiter); - - app.use(global_path + '/libraries', publicLimiter); - app.use(global_path + '/stories', publicLimiter); - app.use(global_path + '/events', publicLimiter); - app.use(global_path + '/media-items', publicLimiter); - - // Apply stricter rate limiting to admin routes - app.use(global_path + '/admin', adminLimiter); - - // Apply search rate limiting to search endpoints - app.get('/api/libraries', (req, res, next) => { - if (req.query.search) { - return searchLimiter(req, res, next); - } - next(); - }); - - app.get('/api/stories', (req, res, next) => { - if (req.query.search) { - return searchLimiter(req, res, next); - } - next(); - }); - - // Authentication routes - app.post(`${global_path}/auth/login`, async (req, res) => { - try { - const { username, password } = req.body; - - if (!username || !password) { - return res.status(400).json({ error: 'Username and password are required' }); - } - - const user = await drizzleService.getUserByUsername(username); - - if (!user) { - return res.status(401).json({ error: 'Invalid username or password' }); - } - - // Compare password using bcrypt - const passwordMatch = await compare(password, user.password); - - if (!passwordMatch) { - return res.status(401).json({ error: 'Invalid username or password' }); - } - - // Create session data - req.session.user = { - id: user.id as string, - username: user.username, - fullName: user.fullName, - email: user.email, - role: user.role, - libraryId: String(user.libraryId) - }; - - // we must create a token here with user data - - // return - return res.status(200).json({ - id: user.id, - username: user.username, - fullName: user.fullName, - email: user.email, - role: user.role, - libraryId: user.libraryId - }); - } catch (error) { - console.error("Login error:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.get(`${global_path}/auth/session`, (req, res) => { - if (req.session.user) { - return res.status(200).json(req.session.user); - } - return res.status(200).json(null); - }); - - app.post(`${global_path}/auth/logout`, (req, res) => { - req.session.destroy((err) => { - if (err) { - return res.status(500).json({ error: 'Failed to logout' }); - } - res.clearCookie('connect.sid'); - return res.status(200).json({ success: true }); - }); - }); - - // Admin story management endpoints - app.post(`${global_path}/admin/stories`, upload.single('featuredImage'), apiHandler(async (req, res) => { - // For testing - remove after - console.log("Session user:", req.session.user); - - // Temporarily allow any role for testing - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - console.log("Creating story with data:", req.body); - - // Create story with library ID from session user or use default for testing - const libraryId = req.session.user.libraryId || 1; - - // Handle featured image upload - let featuredImageUrl = req.body.featuredImageUrl || null; - if (req.file) { - try { - featuredImageUrl = await uploadImageToCloudinary(req.file, 'stories'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload featured image' }); - } - } - - // Create story with library ID from session user - const storyData = { - ...req.body, - libraryId, - featuredImageUrl, - isApproved: false, // New stories need approval - isPublished: req.body.isPublished || false, - isFeatured: false, // Only super admin can feature stories - createdAt: new Date() - }; - - const story = await drizzleService.createStory(storyData); - console.log("Story created successfully:", story); - return res.status(200).json(story); - })); - - // Admin story update endpoint - app.patch(`${global_path}/admin/stories/:id`, upload.single('featuredImage'), apiHandler(async (req, res) => { - // Temporarily relax the role check for testing - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - const storyId = req.params.id; - - // Get the story first to verify ownership - const existingStory = await drizzleService.getStory(storyId); - - if (!existingStory) { - return res.status(404).json({ error: 'Story not found' }); - } - - // Get libraryId from session or fallback for testing - const libraryId = req.session.user.libraryId || existingStory.libraryId; - - // Only do ownership check if we have a proper role - if (req.session.user.role === 'library_admin' && existingStory.libraryId !== libraryId) { - return res.status(403).json({ error: 'Unauthorized - you can only edit stories for your library' }); - } - - // Handle featured image upload - let featuredImageUrl = req.body.featuredImageUrl || existingStory.featuredImageUrl; - if (req.file) { - try { - featuredImageUrl = await uploadImageToCloudinary(req.file, 'stories'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload featured image' }); - } - } - - // Preserve approval status - only super admin can change this - const updateData = { - ...req.body, - featuredImageUrl, - isApproved: existingStory.isApproved, // Preserve approval status - updatedAt: new Date() - }; - - const updatedStory = await drizzleService.updateStory(storyId, updateData); - console.log("Story updated successfully:", updatedStory); - return res.status(200).json(updatedStory); - })); - - // Admin get single story endpoint - app.get(`${global_path}/admin/stories/:id`, apiHandler(async (req, res) => { - // Relaxed authentication for testing - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - const storyId = req.params.id; - const story = await drizzleService.getStory(storyId); - - if (!story) { - return res.status(404).json({ error: 'Story not found' }); - } - - console.log("Found story for editing:", story); - return res.status(200).json(story); - })); - - // Admin timelines endpoints - app.get(`${global_path}/admin/stories/:id/timelines`, apiHandler(async (req, res) => { - // Relaxed authentication for testing - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - const storyId = req.params.id; - - // Get the story first to verify ownership - const story = await drizzleService.getStory(storyId); - - if (!story) { - return res.status(404).json({ error: 'Story not found' }); - } - - // Skip ownership check for testing - // Get the timelines - const timelines = await drizzleService.getTimelinesByStoryId(storyId); - console.log("Retrieved timelines:", timelines); - return res.status(200).json(timelines); - })); - - app.post(`${global_path}/admin/stories/:id/timelines`, apiHandler(async (req, res) => { - // Relaxed authentication for testing - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - const storyId = req.params.id; - - // Get the story first to verify it exists - const story = await drizzleService.getStory(storyId); - - if (!story) { - return res.status(404).json({ error: 'Story not found' }); - } - - // Create timeline data - const timelineData = { - ...req.body, - storyId, - createdAt: new Date(), - updatedAt: new Date() - }; - - console.log("Creating timeline with data:", timelineData); - const timeline = await drizzleService.createTimeline(timelineData); - console.log("Timeline created successfully:", timeline); - return res.status(200).json(timeline); - })); - - - - // Stories endpoints - app.get(`${global_path}/stories`, async (req, res) => { - try { - // Extract query parameters - const libraryId = req.query.libraryId ? String(req.query.libraryId) : undefined; - - // Handle boolean parameters properly - undefined if not provided, explicit boolean if provided - let published = undefined; - if (req.query.published !== undefined) { - published = req.query.published === 'true'; - } - - let approved = undefined; - if (req.query.approved !== undefined) { - approved = req.query.approved === 'true'; - } - - let featured = undefined; - if (req.query.featured !== undefined) { - featured = req.query.featured === 'true'; - } - - const tags = req.query.tag ? Array.isArray(req.query.tag) ? req.query.tag as string[] : [req.query.tag as string] : undefined; - const limit = req.query.limit ? Number(req.query.limit) : undefined; - const offset = req.query.offset ? Number(req.query.offset) : undefined; - - // Pass parameters to storage method with appropriate naming - const stories = await drizzleService.getStories({ - libraryId, - published, - approved, // Fixed to use the correct parameter name for the storage interface - featured, - tags, - limit, - offset - }); - - return res.status(200).json(stories); - } catch (error) { - console.error("Error fetching stories:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Get individual story - app.get(`${global_path}/stories/:id`, async (req, res) => { - try { - // Skip the tags endpoint - special case - if (req.params.id === 'tags') { - // Get all stories - const allStories = await drizzleService.getStories(); - - // Extract all unique tags - const uniqueTags = new Set(); - allStories.forEach((story: Story) => { - if (story.tags && Array.isArray(story.tags)) { - story.tags.forEach((tag: string) => { - if (tag) uniqueTags.add(tag); - }); - } - }); - - return res.status(200).json(Array.from(uniqueTags)); - } - - console.log("hit specific story with id : ", req.params.id) - // Regular story lookup by ID - const storyId = req.params.id; - const story = await drizzleService.getStory(storyId); - - if (!story) { - return res.status(404).json({ error: 'Story not found' }); - } - - return res.status(200).json(story); - } catch (error) { - console.error(`Error fetching story with ID ${req.params.id}:`, error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.post(`${global_path}/libraries`, upload.fields([ - { name: 'logo', maxCount: 1 }, - { name: 'featuredImage', maxCount: 1 } - ]), apiHandler(async (req, res) => { - if (!req.session.user || req.session.user.role !== 'super_admin') { - return res.status(403).json({ error: 'Unauthorized - super admin required' }); - } - - const files = req.files as { [fieldname: string]: Express.Multer.File[] }; - - // Handle logo upload - let logoUrl = req.body.logoUrl || null; - if (files && files['logo'] && files['logo'][0]) { - try { - logoUrl = await uploadImageToCloudinary(files['logo'][0], 'libraries/logos'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload logo' }); - } - } - - // Handle featured image upload - let featuredImageUrl = req.body.featuredImageUrl || null; - if (files && files['featuredImage'] && files['featuredImage'][0]) { - try { - featuredImageUrl = await uploadImageToCloudinary(files['featuredImage'][0], 'libraries/featured'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload featured image' }); - } - } - - const libraryData = { - ...req.body, - logoUrl, - featuredImageUrl, - isApproved: false, // New libraries need approval - createdAt: new Date() - }; - - const library = await drizzleService.createLibrary(libraryData); - return res.status(201).json(library); - })); - - // Update library with image upload - app.patch(`${global_path}/libraries/:id`, upload.fields([ - { name: 'logo', maxCount: 1 }, - { name: 'featuredImage', maxCount: 1 } - ]), apiHandler(async (req, res) => { - if (!req.session.user || (req.session.user.role !== 'super_admin' && req.session.user.role !== 'library_admin')) { - return res.status(403).json({ error: 'Unauthorized' }); - } - - const libraryId = req.params.id; - const existingLibrary = await drizzleService.getLibrary(libraryId); - - if (!existingLibrary) { - return res.status(404).json({ error: 'Library not found' }); - } - - // Check if library admin is updating their own library - if (req.session.user.role === 'library_admin' && req.session.user.libraryId !== libraryId) { - return res.status(403).json({ error: 'Unauthorized - you can only edit your own library' }); - } - - const files = req.files as { [fieldname: string]: Express.Multer.File[] }; - - // Handle logo upload - let logoUrl = req.body.logoUrl || existingLibrary.logoUrl; - if (files && files['logo'] && files['logo'][0]) { - try { - logoUrl = await uploadImageToCloudinary(files['logo'][0], 'libraries/logos'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload logo' }); - } - } - - // Handle featured image upload - let featuredImageUrl = req.body.featuredImageUrl || existingLibrary.featuredImageUrl; - if (files && files['featuredImage'] && files['featuredImage'][0]) { - try { - featuredImageUrl = await uploadImageToCloudinary(files['featuredImage'][0], 'libraries/featured'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload featured image' }); - } - } - - const updateData = { - ...req.body, - logoUrl, - featuredImageUrl, - updatedAt: new Date() - }; - - const updatedLibrary = await drizzleService.updateLibrary(libraryId, updateData); - return res.status(200).json(updatedLibrary); - })); - - // Librarys endpoints - app.get(`${global_path}/libraries`, async (req, res) => { - try { - const libraries = await drizzleService.getLibraries(); - return res.status(200).json(libraries); - } catch (error) { - console.error("Error fetching libraries:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Get individual library - app.get(`${global_path}/libraries/:id`, async (req, res) => { - try { - const libraryId = req.params.id; - const library = await drizzleService.getLibrary(libraryId); - - if (!library) { - return res.status(404).json({ error: 'Library not found' }); - } - - return res.status(200).json(library); - } catch (error) { - console.error(`Error fetching library with ID ${req.params.id}:`, error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Media endpoints - app.get(`${global_path}/media-items`, async (req, res) => { - try { - // Extract query parameters - const libraryId = req.query.libraryId ? String(req.query.libraryId) : undefined; - const galleryId = req.query.galleryId ? String(req.query.galleryId) : undefined; - - // Handle boolean parameters properly - undefined if not provided, explicit boolean if provided - let approved = undefined; - if (req.query.approved !== undefined) { - approved = req.query.approved === 'true'; - } - - const mediaType = req.query.mediaType ? String(req.query.mediaType) : undefined; - const tags = req.query.tag ? Array.isArray(req.query.tag) ? req.query.tag as string[] : [req.query.tag as string] : undefined; - const limit = req.query.limit ? Number(req.query.limit) : undefined; - const offset = req.query.offset ? Number(req.query.offset) : undefined; - - // Pass parameters to storage method with appropriate naming - const media = await drizzleService.getMediaItems({ - libraryId, - galleryId, - mediaType, - tags, - limit, - offset, - approved // Fixed to use the correct parameter name for the storage interface - }); - - return res.status(200).json(media); - } catch (error) { - console.error("Error fetching media:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Get individual media item - app.get(`${global_path}/media-items/:id`, async (req, res) => { - try { - const mediaId = req.params.id; - const mediaItem = await drizzleService.getMediaItem(mediaId); - - if (!mediaItem) { - return res.status(404).json({ error: 'Media item not found' }); - } - - return res.status(200).json(mediaItem); - } catch (error) { - console.error(`Error fetching media item with ID ${req.params.id}:`, error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.post(`${global_path}/media-items`, upload.single('mediaFile'), apiHandler(async (req, res) => { - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - const libraryId = req.session.user.libraryId; - if (!libraryId) { - return res.status(400).json({ error: 'Library ID required' }); - } - - // Handle media file upload - let url = req.body.url || null; - if (req.file) { - try { - url = await uploadImageToCloudinary(req.file, 'media'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload media file' }); - } - } - - if (!url) { - return res.status(400).json({ error: 'Media URL or file is required' }); - } - - const mediaData = { - ...req.body, - libraryId, - url, - isApproved: false, // New media needs approval - createdAt: new Date() - }; - - const mediaItem = await drizzleService.createMediaItem(mediaData); - return res.status(201).json(mediaItem); - })); - - // Update media item with image upload - app.patch(`${global_path}/media-items/:id`, upload.single('mediaFile'), apiHandler(async (req, res) => { - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - const mediaId = req.params.id; - const existingMedia = await drizzleService.getMediaItem(mediaId); - - if (!existingMedia) { - return res.status(404).json({ error: 'Media item not found' }); - } - - // Check ownership - if (req.session.user.role === 'library_admin' && existingMedia.libraryId !== req.session.user.libraryId) { - return res.status(403).json({ error: 'Unauthorized - you can only edit media for your library' }); - } - - // Handle media file upload - let url = req.body.url || existingMedia.url; - if (req.file) { - try { - url = await uploadImageToCloudinary(req.file, 'media'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload media file' }); - } - } - - const updateData = { - ...req.body, - url, - updatedAt: new Date() - }; - - const updatedMedia = await drizzleService.updateMediaItem(mediaId, updateData); - return res.status(200).json(updatedMedia); - })); - - // Super Admin stats endpoint - app.get(`${global_path}/sadmin/stats`, async (req, res) => { - try { - // Get counts of various entities for the dashboard - const libraries = await drizzleService.getLibraries(); - const stories = await drizzleService.getStories(); - const mediaItems = await drizzleService.getMediaItems(); - const usersPromises = libraries.map(library => drizzleService.getUsersByLibraryId(library.id)); - const usersArrays = await Promise.all(usersPromises); - const users = usersArrays.flat(); - - // Sample placeholder data - in a real app this would come from actual data - const stats = { - totalLibraries: libraries.length, - pendingLibraries: libraries.filter(m => !m.isApproved).length, - totalStories: stories.length, - pendingStories: stories.filter(s => !s.isApproved).length, - totalMedia: mediaItems.length, - uniqueGalleries: Array.from(new Set(mediaItems.map(m => m.galleryId))).length, - totalUsers: users.length, - activeUsers: users.filter(u => u.lastLoginAt !== null).length, - recentActivity: [ - { type: 'user_signup', user: 'National Gallery Admin', timestamp: new Date(Date.now() - 1000 * 60 * 5) }, - { type: 'story_published', user: 'MoMA Admin', title: 'Summer Exhibition Preview', timestamp: new Date(Date.now() - 1000 * 60 * 60) }, - { type: 'media_uploaded', user: 'Louvre Admin', count: 15, timestamp: new Date(Date.now() - 1000 * 60 * 60 * 3) }, - { type: 'library_approved', user: 'Super Admin', library: 'Contemporary Arts Center', timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24) } - ] - }; - - return res.status(200).json(stats); - } catch (error) { - console.error("Error fetching super admin stats:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.post(`${global_path}/events`, upload.single('eventImage'), apiHandler(async (req, res) => { - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - const libraryId = req.session.user.libraryId; - if (!libraryId) { - return res.status(400).json({ error: 'Library ID required' }); - } - - // Handle event image upload - let imageUrl = req.body.imageUrl || null; - if (req.file) { - try { - imageUrl = await uploadImageToCloudinary(req.file, 'events'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload event image' }); - } - } - - const eventData = { - ...req.body, - libraryId, - imageUrl, - isApproved: false, // New events need approval - createdAt: new Date() - }; - - const event = await drizzleService.createEvent(eventData); - return res.status(201).json(event); - })); - - // Update event with image upload - app.patch(`${global_path}/events/:id`, upload.single('eventImage'), apiHandler(async (req, res) => { - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - const eventId = req.params.id; - const existingEvent = await drizzleService.getEvent(eventId); - - if (!existingEvent) { - return res.status(404).json({ error: 'Event not found' }); - } - - // Check ownership - if (req.session.user.role === 'library_admin' && existingEvent.libraryId !== req.session.user.libraryId) { - return res.status(403).json({ error: 'Unauthorized - you can only edit events for your library' }); - } - - // Handle event image upload - let imageUrl = req.body.imageUrl || existingEvent.imageUrl; - if (req.file) { - try { - imageUrl = await uploadImageToCloudinary(req.file, 'events'); - } catch (error) { - return res.status(500).json({ error: 'Failed to upload event image' }); - } - } - - const updateData = { - ...req.body, - imageUrl, - updatedAt: new Date() - }; - - const updatedEvent = await drizzleService.updateEvent(eventId, updateData); - return res.status(200).json(updatedEvent); - })); - - // Super Admin moderation endpoints - app.get(`${global_path}/superadmin/moderation/stories`, async (req, res) => { - try { - // Get stories that need approval - const pendingStories = await drizzleService.getStories({ approved: false }); - return res.status(200).json(pendingStories); - } catch (error) { - console.error("Error fetching pending stories:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.get(`${global_path}/superadmin/moderation/media`, async (req, res) => { - try { - // Get media items that need approval - const pendingMedia = await drizzleService.getMediaItems({ approved: false }); - return res.status(200).json(pendingMedia); - } catch (error) { - console.error("Error fetching pending media:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.patch(`${global_path}/superadmin/stories/:id/approve`, async (req, res) => { - try { - const storyId = req.params.id; - // Fix DB column mismatch by using appropriate naming - const updatedStory = await drizzleService.updateStory(storyId, { - isApproved: true // Keep using isApproved as this is for the DB field name - }); - - if (!updatedStory) { - return res.status(404).json({ error: 'Story not found' }); - } - - return res.status(200).json(updatedStory); - } catch (error) { - console.error("Error approving story:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.patch(`${global_path}/superadmin/stories/:id/reject`, async (req, res) => { - try { - const storyId = req.params.id; - const updatedStory = await drizzleService.updateStory(storyId, { isApproved: false }); - - if (!updatedStory) { - return res.status(404).json({ error: 'Story not found' }); - } - - return res.status(200).json(updatedStory); - } catch (error) { - console.error("Error rejecting story:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.patch(`${global_path}/superadmin/media-items/:id/approve`, async (req, res) => { - try { - const mediaId = req.params.id; - const updatedMedia = await drizzleService.updateMediaItem(mediaId, { isApproved: true }); - - if (!updatedMedia) { - return res.status(404).json({ error: 'Media item not found' }); - } - - return res.status(200).json(updatedMedia); - } catch (error) { - console.error("Error approving media:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.patch(`${global_path}/superadmin/media-items/:id/reject`, async (req, res) => { - try { - const mediaId = req.params.id; - const updatedMedia = await drizzleService.updateMediaItem(mediaId, { isApproved: false }); - - if (!updatedMedia) { - return res.status(404).json({ error: 'Media item not found' }); - } - - return res.status(200).json(updatedMedia); - } catch (error) { - console.error("Error rejecting media:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Super Admin libraries endpoint - app.get(`${global_path}/superadmin/libraries`, async (req, res) => { - try { - const libraries = await drizzleService.getLibraries(); - return res.status(200).json(libraries); - } catch (error) { - console.error("Error fetching libraries:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Super Admin users endpoint - app.get(`${global_path}/superadmin/users`, async (req, res) => { - try { - // Get all users across all libraries - const libraries = await drizzleService.getLibraries(); - const usersPromises = libraries.map(library => drizzleService.getUsersByLibraryId(library.id)); - const usersArrays = await Promise.all(usersPromises); - const users = usersArrays.flat(); - - return res.status(200).json(users); - } catch (error) { - console.error("Error fetching users:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.post(`${global_path}/superadmin/users`, async (req, res) => { - try { - const userData = req.body; - - // Validate required fields - if (!userData.username || !userData.password || !userData.email || !userData.fullName || !userData.role) { - return res.status(400).json({ error: 'Missing required fields' }); - } - - const hashedPassword = await bcrypt.hash(userData.password, 10); - - const newUser = await drizzleService.createUser({ - username: userData.username, - password: hashedPassword, - email: userData.email, - fullName: userData.fullName, - role: userData.role, - libraryId: userData.libraryId || null, - isActive: userData.isActive !== undefined ? userData.isActive : true - }); - - return res.status(201).json(newUser); - } catch (error: any) { - console.error("Error creating user:", error); - - - // Handle duplicate username/email errors - if (error.code === '23505') { // PostgreSQL unique violation code - if (error.constraint === 'users_username_unique') { - return res.status(409).json({ - error: 'Username already in use. Please choose a different one.' - }); - } - if (error.constraint === 'users_email_unique') { - return res.status(409).json({ - error: 'Email already in use. Please use a different email.' - }); - } - } - - return res.status(500).json({ error: error?.message || 'Internal server error' }); - } - }); - - app.patch(`${global_path}/superadmin/users/:id`, async (req, res) => { - try { - const userId = req.params.id; - const updateData = req.body; - - const updatedUser = await drizzleService.updateUser(userId, updateData); - - if (!updatedUser) { - return res.status(404).json({ error: 'User not found' }); - } - - return res.status(200).json(updatedUser); - } catch (error) { - console.error("Error updating user:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.post(`${global_path}/superadmin/users/:id/reset-password`, async (req, res) => { - try { - const userId = req.params.id; - const { password } = req.body; - - if (!password) { - return res.status(400).json({ error: 'Password is required' }); - } - - // Hash new password - const hashedPassword = await bcrypt.hash(password, 10); - - const updatedUser = await drizzleService.updateUser(userId, { password: hashedPassword }); - - if (!updatedUser) { - return res.status(404).json({ error: 'User not found' }); - } - - return res.status(200).json({ message: 'Password reset successfully' }); - } catch (error) { - console.error("Error resetting password:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - - // Maintenance endpoints - let maintenanceMode = false; - const maintenanceWindows: any[] = []; - const backupHistory: any[] = [ - { id: 1, type: 'full', size: '2.3 GB', created: new Date('2025-06-18T02:00:00Z'), status: 'completed' }, - { id: 2, type: 'database', size: '890 MB', created: new Date('2025-06-17T02:00:00Z'), status: 'completed' }, - { id: 3, type: 'files', size: '1.4 GB', created: new Date('2025-06-16T02:00:00Z'), status: 'completed' }, - { id: 4, type: 'database', size: '885 MB', created: new Date('2025-06-15T02:00:00Z'), status: 'completed' }, - ]; - - // health check endpoint - app.get(`${global_path}/health`, async (req, res) => { - try { - const isHealthy = await drizzleService.healthCheck(); - res.json({ - status: isHealthy ? 'system healthy' : 'system unhealthy', - timestamp: new Date().toISOString() - }); - } catch (error) { - res.status(500).json({ - status: 'system unhealthy', - error: 'Health check failed', - timestamp: new Date().toISOString() - }); - } - }); - - // Get maintenance status - app.get(`${global_path}/maintenance/status`, async (req, res) => { - try { - const systemHealth = [ - { service: 'Web Server', status: 'healthy', uptime: '15 days, 3 hours', responseTime: 145, lastCheck: new Date() }, - { service: 'Database', status: 'healthy', uptime: '15 days, 3 hours', responseTime: 23, lastCheck: new Date() }, - { service: 'File Storage', status: 'warning', uptime: '2 days, 1 hour', responseTime: 287, lastCheck: new Date() }, - { service: 'Email Service', status: 'healthy', uptime: '15 days, 3 hours', responseTime: 412, lastCheck: new Date() }, - { service: 'CDN', status: 'healthy', uptime: '30 days, 12 hours', responseTime: 89, lastCheck: new Date() }, - ]; - - const systemMetrics = { - cpuUsage: Math.floor(Math.random() * 30) + 15, - memoryUsage: Math.floor(Math.random() * 40) + 50, - diskUsage: Math.floor(Math.random() * 30) + 30, - networkTraffic: '1.2 GB/day' - }; - - return res.status(200).json({ - maintenanceMode, - systemHealth, - systemMetrics, - maintenanceWindows, - backupHistory - }); - } catch (error) { - console.error("Error fetching maintenance status:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Toggle maintenance mode - app.post(`${global_path}/maintenance/toggle`, async (req, res) => { - try { - const { enabled } = req.body; - maintenanceMode = enabled; - - return res.status(200).json({ - success: true, - maintenanceMode, - message: `Maintenance mode ${enabled ? 'enabled' : 'disabled'}` - }); - } catch (error) { - console.error("Error toggling maintenance mode:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Schedule maintenance window - app.post(`${global_path}/maintenance/schedule`, async (req, res) => { - try { - const { title, description, scheduledStart, scheduledEnd, affectedServices } = req.body; - - if (!title || !scheduledStart) { - return res.status(400).json({ error: 'Title and start time are required' }); - } - - const newWindow = { - id: Date.now(), - title, - description, - scheduledStart: new Date(scheduledStart), - scheduledEnd: scheduledEnd ? new Date(scheduledEnd) : null, - affectedServices: affectedServices || [], - status: 'scheduled', - createdAt: new Date() - }; - - maintenanceWindows.push(newWindow); - - return res.status(201).json(newWindow); - } catch (error) { - console.error("Error scheduling maintenance:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Create backup - app.post(`${global_path}/maintenance/backup`, async (req, res) => { - try { - const { type } = req.body; - - if (!['database', 'files', 'full'].includes(type)) { - return res.status(400).json({ error: 'Invalid backup type' }); - } - - // Simulate backup creation - const sizes = { - database: `${Math.floor(Math.random() * 500) + 800} MB`, - files: `${Math.floor(Math.random() * 800) + 1200} MB`, - full: `${Math.floor(Math.random() * 1000) + 2000} MB` - }; - - const newBackup = { - id: Date.now(), - type, - size: sizes[type as keyof typeof sizes], - created: new Date(), - status: 'running' - }; - - backupHistory.unshift(newBackup); - - // Simulate backup completion after 3 seconds - setTimeout(() => { - const backup = backupHistory.find(b => b.id === newBackup.id); - if (backup) { - backup.status = 'completed'; - } - }, 3000); - - return res.status(201).json(newBackup); - } catch (error) { - console.error("Error creating backup:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Get backup history - app.get(`${global_path}/maintenance/backups`, async (req, res) => { - try { - return res.status(200).json(backupHistory); - } catch (error) { - console.error("Error fetching backups:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Refresh system status - app.post(`${global_path}/maintenance/refresh`, async (req, res) => { - try { - // Simulate system check with random variations - const systemHealth = [ - { - service: 'Web Server', - status: 'healthy', - uptime: '15 days, 3 hours', - responseTime: Math.floor(Math.random() * 50) + 120, - lastCheck: new Date() - }, - { - service: 'Database', - status: 'healthy', - uptime: '15 days, 3 hours', - responseTime: Math.floor(Math.random() * 20) + 15, - lastCheck: new Date() - }, - { - service: 'File Storage', - status: Math.random() > 0.8 ? 'warning' : 'healthy', - uptime: '2 days, 1 hour', - responseTime: Math.floor(Math.random() * 100) + 200, - lastCheck: new Date() - }, - { - service: 'Email Service', - status: 'healthy', - uptime: '15 days, 3 hours', - responseTime: Math.floor(Math.random() * 200) + 350, - lastCheck: new Date() - }, - { - service: 'CDN', - status: 'healthy', - uptime: '30 days, 12 hours', - responseTime: Math.floor(Math.random() * 30) + 70, - lastCheck: new Date() - }, - ]; - - return res.status(200).json({ systemHealth }); - } catch (error) { - console.error("Error refreshing system status:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Events endpoints - app.get(`${global_path}/events`, async (req, res) => { - try { - const libraryId = req.session.user?.libraryId; - const options = libraryId ? { libraryId } : {}; - - const events = await drizzleService.getEvents(options); - return res.status(200).json(events); - } catch (error) { - console.error("Error fetching events:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.delete(`${global_path}/events/:id`, async (req, res) => { - try { - const eventId = req.params.id; - const deleted = await drizzleService.deleteEvent(eventId); - - if (!deleted) { - return res.status(404).json({ error: 'Event not found' }); - } - - return res.status(200).json({ success: true }); - } catch (error) { - console.error("Error deleting event:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Contact messages endpoints - app.get(`${global_path}/contact-messages`, async (req, res) => { - try { - const libraryId = req.session.user?.libraryId; - const options = libraryId ? { libraryId } : {}; - - const messages = await drizzleService.getContactMessages(options); - return res.status(200).json(messages); - } catch (error) { - console.error("Error fetching contact messages:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.post(`${global_path}/contact-messages`, contactLimiter, async (req, res) => { - try { - const message = await drizzleService.createContactMessage(req.body); - return res.status(201).json(message); - } catch (error) { - console.error("Error creating contact message:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.patch(`${global_path}/contact-messages/:id`, async (req, res) => { - try { - const messageId = req.params.id; - const updatedMessage = await drizzleService.updateContactMessage(messageId, req.body); - - if (!updatedMessage) { - return res.status(404).json({ error: 'Contact message not found' }); - } - - return res.status(200).json(updatedMessage); - } catch (error) { - console.error("Error updating contact message:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Reply to a contact message - app.post(`${global_path}/contact-messages/:id/reply`, emailLimiter, jsonApiMiddleware, apiHandler(async (req, res) => { - if (!req.session.user || req.session.user.role !== 'library_admin') { - return res.status(403).json({ error: "Unauthorized" }); - } - - const messageId = req.params.id; - const { subject, message } = req.body; - - if (!subject || !message) { - return res.status(400).json({ error: "Subject and message are required" }); - } - - // Get the original message - const originalMessage = await drizzleService.getContactMessage(messageId); - if (!originalMessage || originalMessage.libraryId !== req.session.user.libraryId) { - return res.status(404).json({ error: "Message not found" }); - } - - // Get library information - const library = await drizzleService.getLibrary(req.session.user.libraryId!); - if (!library) { - return res.status(404).json({ error: "Library not found" }); - } - - try { - // Send email response to visitor - const emailSent = await sendResponseEmail({ - visitorEmail: originalMessage.email, - visitorName: originalMessage.name, - originalSubject: originalMessage.subject, - responseSubject: subject, - responseMessage: message, - libraryName: library.name, - libraryEmail: "noreply@library.com" - }); - - if (!emailSent) { - return res.status(500).json({ error: "Failed to send email response" }); - } - - // Create message response record - const response = await drizzleService.createMessageResponse({ - contactMessageId: messageId, - respondedBy: req.session.user.id, - subject, - message - }); - - // Update contact message status - await drizzleService.updateContactMessage(messageId, { - responseStatus: 'responded', - isRead: true - }); - - res.json(response); - } catch (error) { - console.error('Error sending reply:', error); - res.status(500).json({ error: "Failed to send reply" }); - } - })); - - // Analytics endpoints - app.get(`${global_path}/admin/dashboard/stats`, async (req, res) => { - try { - const libraryId = req.session.user?.libraryId; - if (!libraryId) { - return res.status(400).json({ error: 'Library ID required' }); - } - - const stories = await drizzleService.getStories({ libraryId }); - const mediaItems = await drizzleService.getMediaItems({ libraryId }); - const events = await drizzleService.getEvents({ libraryId }); - const messages = await drizzleService.getContactMessages({ libraryId }); - - const stats = { - totalStories: stories.length, - publishedStories: stories.filter(s => s.isPublished).length, - totalMedia: mediaItems.length, - approvedMedia: mediaItems.filter(m => m.isApproved).length, - totalEvents: events.length, - upcomingEvents: events.filter(e => new Date(e.eventDate) > new Date()).length, - totalMessages: messages.length, - unreadMessages: messages.filter(m => !m.isRead).length - }; - - return res.status(200).json(stats); - } catch (error) { - console.error("Error fetching dashboard stats:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.get(`${global_path}/admin/dashboard/analytics`, async (req, res) => { - try { - const libraryId = req.session.user?.libraryId; - if (!libraryId) { - return res.status(400).json({ error: 'Library ID required' }); - } - - const analytics = await drizzleService.getAnalytics({ libraryId }); - - // Process analytics data for charts - const last30Days = Array.from({ length: 30 }, (_, i) => { - const date = new Date(); - date.setDate(date.getDate() - i); - return date.toISOString().split('T')[0]; - }).reverse(); - - const visitorData = last30Days.map(date => { - const dayAnalytics = analytics.filter(a => - a.date && new Date(a.date).toISOString().split('T')[0] === date - ); - const totalViews = dayAnalytics.reduce((sum, a) => sum + (a.views || 0), 0); - - return { - date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), - visitors: totalViews, - uniqueVisitors: Math.floor(totalViews * 0.7) // Approximate unique visitors - }; - }); - - const contentData = [ - { name: 'Stories', views: analytics.filter(a => a.storyId).reduce((sum, a) => sum + (a.views || 0), 0), engagement: 75 }, - { name: 'Gallery', views: analytics.filter(a => a.pageType === 'gallery').reduce((sum, a) => sum + (a.views || 0), 0), engagement: 85 }, - { name: 'Library Profile', views: analytics.filter(a => a.pageType === 'library_profile').reduce((sum, a) => sum + (a.views || 0), 0), engagement: 65 } - ]; - - const engagementData = last30Days.slice(-7).map(date => ({ - date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), - avgTimeSpent: Math.floor(Math.random() * 300) + 120, // Mock data for demo - interactionRate: Math.floor(Math.random() * 40) + 60 - })); - - const topPerformers = { - topStory: 'Featured Exhibition', - topStoryViews: Math.max(...analytics.filter(a => a.storyId).map(a => a.views || 0), 0), - topGallery: 'Main Collection', - topGalleryViews: Math.max(...analytics.filter(a => a.pageType === 'gallery').map(a => a.views || 0), 0), - avgTimeOnPage: '4:32', - avgTimeIncrease: 12 - }; - - return res.status(200).json({ - visitorData, - contentData, - engagementData, - topPerformers - }); - } catch (error) { - console.error("Error fetching analytics:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - app.get(`${global_path}/admin/dashboard/activity`, async (req, res) => { - try { - const libraryId = req.session.user?.libraryId; - if (!libraryId) { - return res.status(400).json({ error: 'Library ID required' }); - } - - const stories = await drizzleService.getStories({ libraryId, limit: 5 }); - const messages = await drizzleService.getContactMessages({ libraryId, limit: 5 }); - const events = await drizzleService.getEvents({ libraryId, limit: 5 }); - - const recentActivity = [ - ...stories.map(s => ({ - type: 'story', - title: `Story updated: ${s.title}`, - timestamp: s.updatedAt || s.createdAt, - status: s.isPublished ? 'published' : 'draft' - })), - ...messages.map(m => ({ - type: 'message', - title: `New inquiry: ${m.subject}`, - timestamp: m.createdAt, - status: m.isRead ? 'read' : 'unread' - })), - ...events.map(e => ({ - type: 'event', - title: `Event: ${e.title}`, - timestamp: e.createdAt, - status: e.isPublished ? 'published' : 'draft' - })) - ].sort((a, b) => - new Date(b.timestamp ?? 0).getTime() - new Date(a.timestamp ?? 0).getTime() - ).slice(0, 10); - - return res.status(200).json(recentActivity); - } catch (error) { - console.error("Error fetching recent activity:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - - // Settings endpoints - let platformSettings = { - general: { - siteName: "Library Digital Platform", - siteDescription: "A comprehensive platform for library digital experiences", - contactEmail: "contact@library-platform.com", - supportEmail: "support@library-platform.com", - defaultLanguage: "en", - timezone: "UTC", - allowRegistration: true, - requireEmailVerification: true, - maintenanceMode: false, - }, - security: { - passwordMinLength: 8, - requireStrongPasswords: true, - sessionTimeout: 24, - maxLoginAttempts: 5, - enableTwoFactor: false, - allowPasswordReset: true, - }, - email: { - smtpHost: "", - smtpPort: 587, - smtpUser: "", - smtpPassword: "", - fromEmail: "noreply@library-platform.com", - fromName: "Library Platform", - enableEmailNotifications: true, - }, - content: { - maxFileSize: 10, - allowedFileTypes: ["jpg", "jpeg", "png", "gif", "pdf", "mp4", "mp3"], - autoModeration: true, - requireApproval: true, - enableComments: true, - enableRatings: true, - }, - appearance: { - primaryColor: "#2563eb", - secondaryColor: "#64748b", - logo: "", - favicon: "", - customCSS: "", - darkModeEnabled: true, - }, - notifications: { - newUserSignup: true, - newLibraryApplication: true, - contentFlagged: true, - systemAlerts: true, - weeklyReports: true, - emailDigest: false, - } - }; - - // Get platform settings - app.get(`${global_path}/settings`, async (req, res) => { - try { - return res.status(200).json(platformSettings); - } catch (error) { - console.error("Error fetching settings:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Update platform settings - app.post(`${global_path}/settings`, async (req, res) => { - try { - const updates = req.body; - - // Merge updates with existing settings - platformSettings = { ...platformSettings, ...updates }; - - return res.status(200).json(platformSettings); - } catch (error) { - console.error("Error updating settings:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Test email configuration - app.post(`${global_path}/settings/test-email`, async (req, res) => { - try { - // Simulate email test - return res.status(200).json({ message: 'Test email sent successfully' }); - } catch (error) { - console.error("Error testing email:", error); - return res.status(500).json({ error: 'Failed to send test email' }); - } - }); - - // Get all story tags - app.get(`${global_path}/stories/tags`, async (req, res) => { - try { - const stories = await drizzleService.getStories({ published: true, approved: true }); - const allTags = new Set(); - - stories.forEach(story => { - if (story.tags && Array.isArray(story.tags)) { - story.tags.forEach(tag => allTags.add(tag)); - } - }); - - const sortedTags = Array.from(allTags).sort(); - return res.status(200).json(sortedTags); - } catch (error) { - console.error("Error fetching story tags:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - // Delete Image Route - app.delete(global_path + '/admin/upload/image/:publicId', apiHandler(async (req: Request, res: Response) => { - const { publicId } = req.params; - - if (!cloudinaryService.isReady()) { - return res.status(503).json({ error: 'Cloudinary not configured' }); - } - - try { - // Decode the public ID (it may be URL encoded) - const decodedPublicId = decodeURIComponent(publicId); - const success = await cloudinaryService.deleteImage(decodedPublicId); - - if (success) { - return res.status(200).json({ success: true, message: 'Image deleted successfully' }); - } else { - return res.status(404).json({ error: 'Image not found or already deleted' }); - } - } catch (error) { - console.error("Image deletion error:", error); - return res.status(500).json({ - error: 'Failed to delete image', - message: error instanceof Error ? error.message : String(error) - }); - } - })); - - // Admin: Get all galleries - app.get(`${global_path}/admin/galleries`, apiHandler(async (req, res) => { - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - const galleries = await drizzleService.getGalleries(); - return res.status(200).json(galleries); - })); - - // Admin: Get all unique media tags - app.get(`${global_path}/admin/media/tags`, apiHandler(async (req, res) => { - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - const mediaItems = await drizzleService.getMediaItems(); - const allTags = new Set(); - mediaItems.forEach(item => { - if (item.tags && Array.isArray(item.tags)) { - item.tags.forEach(tag => allTags.add(tag)); - } - }); - const sortedTags = Array.from(allTags).sort(); - return res.status(200).json(sortedTags); - })); - - const httpServer = createServer(app); - - return httpServer; -} diff --git a/src/config/env.ts b/src/config/env.ts index 63111d1..1c5d24d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -45,6 +45,8 @@ const envSchema = z.object({ // Email - optional (only needed for email functionality) GMAIL_USER: z.string().email().optional(), GMAIL_PASS: z.string().optional(), + /** Contact email shown in API docs (e.g. Swagger). */ + GMAIL_APP_SUPPORT: z.string().email().optional(), // CORS - defaults to common development origins ALLOWED_ORIGINS: z.string().default('http://localhost:3000,http://localhost:3001'), diff --git a/src/controllers/admin.controller.ts b/src/controllers/admin.controller.ts new file mode 100644 index 0000000..a4f1dd8 --- /dev/null +++ b/src/controllers/admin.controller.ts @@ -0,0 +1,76 @@ +/** + * Admin Controller + * + * Library-admin scoped: dashboard stats/analytics/activity, galleries, and + * image deletion. Uses session user's libraryId where applicable. + * + * @module src/controllers/admin.controller + */ + +import { Request, Response } from 'express'; +import { adminService } from '../services/admin.service'; + +export class AdminController { + /** + * Returns dashboard stats (stories, media, events, messages counts) for the session user's library. + * @param req - Express request; session must contain user with libraryId + * @param res - Express response; sends JSON stats + */ + async getDashboardStats(req: Request, res: Response): Promise { + const libraryId = req.session.user?.libraryId; + if (!libraryId) { + throw new Error('Library ID required'); + } + + const stats = await adminService.getDashboardStats(libraryId); + res.status(200).json(stats); + } + + async getDashboardAnalytics(req: Request, res: Response): Promise { + const libraryId = req.session.user?.libraryId; + if (!libraryId) { + throw new Error('Library ID required'); + } + + const analytics = await adminService.getDashboardAnalytics(libraryId); + res.status(200).json(analytics); + } + + /** + * Returns recent activity (stories, messages, events) for the session user's library. + * @param req - Express request; session must contain user with libraryId + * @param res - Express response; sends JSON activity list + */ + async getDashboardActivity(req: Request, res: Response): Promise { + const libraryId = req.session.user?.libraryId; + if (!libraryId) { + throw new Error('Library ID required'); + } + + const activity = await adminService.getDashboardActivity(libraryId); + res.status(200).json(activity); + } + + /** + * Returns list of all galleries (no library filter). + * @param req - Express request + * @param res - Express response; sends JSON array of galleries + */ + async getGalleries(req: Request, res: Response): Promise { + const galleries = await adminService.getGalleries(); + res.status(200).json(galleries); + } + + /** + * Deletes an image from Cloudinary by public ID. Requires session user. + * @param req - Express request; params.publicId is the Cloudinary public ID + * @param res - Express response; sends success message + */ + async deleteImage(req: Request, res: Response): Promise { + const { publicId } = req.params; + await adminService.deleteImage(publicId); + res.status(200).json({ success: true, message: 'Image deleted successfully' }); + } +} + +export const adminController = new AdminController(); diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index fed7fe3..5fc2dd2 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,52 +1,48 @@ +/** + * Authentication Controller + * + * Handles login (session creation), session retrieval, and logout. Uses + * authService for credential verification and populates req.session.user + * on success. + * + * @module src/controllers/auth.controller + */ + import { Request, Response } from 'express'; -import { compare } from 'bcrypt'; -import drizzleService from '../../services/drizzle-services'; -import { AuthenticationError } from '../utils/errors'; +import { authService } from '../services/auth.service'; import { logger } from '../middlewares/logger'; export class AuthController { + /** + * Authenticates credentials and creates a session. Responds with user data + * (id, username, fullName, email, role, libraryId). Expects req.body to be + * validated by loginSchema (username, password). + */ async login(req: Request, res: Response): Promise { const { username, password } = req.body; - const user = await drizzleService.getUserByUsername(username); - - if (!user) { - throw new AuthenticationError('Invalid username or password'); - } - - const passwordMatch = await compare(password, user.password); - - if (!passwordMatch) { - throw new AuthenticationError('Invalid username or password'); - } + const sessionUser = await authService.authenticateUser({ username, password }); - // Create session data - req.session.user = { - id: user.id as string, - username: user.username, - fullName: user.fullName, - email: user.email, - role: user.role, - libraryId: String(user.libraryId), - }; + req.session.user = sessionUser; - logger.info('User logged in', { userId: user.id, username: user.username }); + logger.info('User logged in', { userId: sessionUser.id, username: sessionUser.username }); res.status(200).json({ success: true, data: { - id: user.id, - username: user.username, - fullName: user.fullName, - email: user.email, - role: user.role, - libraryId: user.libraryId, + id: sessionUser.id, + username: sessionUser.username, + fullName: sessionUser.fullName, + email: sessionUser.email, + role: sessionUser.role, + libraryId: sessionUser.libraryId, }, }); } + /** Returns current session user or null if not authenticated. */ async getSession(req: Request, res: Response): Promise { - if (req.session.user) { + if (req.session?.user) { res.status(200).json({ success: true, data: req.session.user, @@ -59,6 +55,7 @@ export class AuthController { } } + /** Destroys the session and clears the session cookie. */ async logout(req: Request, res: Response): Promise { return new Promise((resolve, reject) => { req.session.destroy((err) => { diff --git a/src/controllers/contact.controller.ts b/src/controllers/contact.controller.ts new file mode 100644 index 0000000..674f967 --- /dev/null +++ b/src/controllers/contact.controller.ts @@ -0,0 +1,69 @@ +/** + * Contact Controller + * + * Contact form messages: list (filtered by library for admins), create, update, + * and reply. Reply is restricted to library_admin role. + * + * @module src/controllers/contact.controller + */ + +import { Request, Response } from 'express'; +import { contactService } from '../services/contact.service'; +import { AuthorizationError } from '../utils/errors'; + +export class ContactController { + /** + * Lists contact messages; when libraryId is present in session, filters by that library. + * @param req - Express request; optional session.user.libraryId + * @param res - Express response; sends JSON array of messages + */ + async getContactMessages(req: Request, res: Response): Promise { + const libraryId = req.session.user?.libraryId; + const messages = await contactService.getContactMessages(libraryId); + res.status(200).json(messages); + } + + /** + * Creates a new contact form message from validated body. + * @param req - Express request; body validated by contact schema + * @param res - Express response; sends 201 and created message + */ + async createContactMessage(req: Request, res: Response): Promise { + const message = await contactService.createContactMessage(req.body); + res.status(201).json(message); + } + + async updateContactMessage(req: Request, res: Response): Promise { + const messageId = req.params.id; + const message = await contactService.updateContactMessage(messageId, req.body); + res.status(200).json(message); + } + + /** + * Sends an email reply to a contact message and records the response. Restricted to library_admin. + * @param req - Express request; params.id, body.subject, body.message; session user required + * @param res - Express response; sends created message response + */ + async replyToMessage(req: Request, res: Response): Promise { + if (!req.session.user || req.session.user.role !== 'library_admin') { + throw new AuthorizationError('Unauthorized'); + } + + const messageId = req.params.id; + const { subject, message } = req.body; + const userId = req.session.user.id; + const libraryId = req.session.user.libraryId!; + + const response = await contactService.replyToMessage( + messageId, + subject, + message, + userId, + libraryId + ); + + res.json(response); + } +} + +export const contactController = new ContactController(); diff --git a/src/controllers/events.controller.ts b/src/controllers/events.controller.ts new file mode 100644 index 0000000..50a52e5 --- /dev/null +++ b/src/controllers/events.controller.ts @@ -0,0 +1,80 @@ +/** + * Events Controller + * + * CRUD for events. Create/update require authenticated user with libraryId; + * list and delete are scoped by session or params as appropriate. + * + * @module src/controllers/events.controller + */ + +import { Request, Response } from 'express'; +import { eventsService } from '../services/events.service'; +import { AuthorizationError } from '../utils/errors'; + +export class EventsController { + /** + * Creates a new event for the session user's library; optional event image upload. + * @param req - Express request; body + optional req.file for image + * @param res - Express response; sends 201 and created event + */ + async createEvent(req: Request, res: Response): Promise { + if (!req.session.user) { + throw new AuthorizationError('Unauthorized - not logged in'); + } + const libraryId = req.session.user.libraryId; + if (!libraryId) { + throw new AuthorizationError('Library context required'); + } + const event = await eventsService.createEvent(req.body, libraryId, req.file); + + res.status(201).json(event); + } + + /** + * Updates an event by ID; enforces library ownership for library_admin. Optional image upload. + * @param req - Express request; params.id, body, optional req.file + * @param res - Express response; sends updated event + */ + async updateEvent(req: Request, res: Response): Promise { + if (!req.session.user) { + throw new AuthorizationError('Unauthorized - not logged in'); + } + const libraryId = req.session.user.libraryId; + if (!libraryId) { + throw new AuthorizationError('Library context required'); + } + const eventId = req.params.id; + const role = req.session.user.role; + + const updatedEvent = await eventsService.updateEvent( + eventId, + req.body, + libraryId, + role, + req.file + ); + + res.status(200).json(updatedEvent); + } + + async getEvents(req: Request, res: Response): Promise { + const libraryId = req.session.user?.libraryId; + const options = libraryId ? { libraryId } : {}; + + const events = await eventsService.getEvents(options); + res.status(200).json(events); + } + + /** + * Deletes an event by ID. + * @param req - Express request; params.id + * @param res - Express response; sends success + */ + async deleteEvent(req: Request, res: Response): Promise { + const eventId = req.params.id; + await eventsService.deleteEvent(eventId); + res.status(200).json({ success: true }); + } +} + +export const eventsController = new EventsController(); diff --git a/src/controllers/libraries.controller.ts b/src/controllers/libraries.controller.ts new file mode 100644 index 0000000..2713331 --- /dev/null +++ b/src/controllers/libraries.controller.ts @@ -0,0 +1,78 @@ +/** + * Libraries Controller + * + * CRUD for libraries. Create accepts multipart body and files; update is + * restricted by session libraryId and role. List and get by ID are public. + * + * @module src/controllers/libraries.controller + */ + +import { Request, Response } from 'express'; +import { librariesService } from '../services/libraries.service'; +import { AuthorizationError } from '../utils/errors'; + +export class LibrariesController { + /** + * Creates a new library with optional logo and featured image uploads (multipart). + * @param req - Express request; body + optional files + * @param res - Express response; sends 201 and created library + */ + async createLibrary(req: Request, res: Response): Promise { + const files = req.files as { [fieldname: string]: Express.Multer.File[] }; + const library = await librariesService.createLibrary(req.body, files); + + res.status(201).json({ + success: true, + data: library, + }); + } + + /** + * Updates a library by ID; enforces session libraryId and role (library_admin can only edit own library). + * @param req - Express request; params.id, body, optional files + * @param res - Express response; sends updated library + */ + async updateLibrary(req: Request, res: Response): Promise { + if (!req.session.user) { + throw new AuthorizationError('Unauthorized - not logged in'); + } + const libraryIdFromSession = req.session.user.libraryId; + if (!libraryIdFromSession) { + throw new AuthorizationError('Library context required'); + } + const libraryId = req.params.id; + const role = req.session.user.role; + const files = req.files as { [fieldname: string]: Express.Multer.File[] }; + + const updatedLibrary = await librariesService.updateLibrary( + libraryId, + req.body, + libraryIdFromSession, + role, + files + ); + + res.status(200).json({ + success: true, + data: updatedLibrary, + }); + } + + async getLibraries(req: Request, res: Response): Promise { + const libraries = await librariesService.getLibraries(); + res.status(200).json(libraries); + } + + /** + * Returns a single library by ID (public). + * @param req - Express request; params.id + * @param res - Express response; sends library object + */ + async getLibrary(req: Request, res: Response): Promise { + const libraryId = req.params.id; + const library = await librariesService.getLibrary(libraryId); + res.status(200).json(library); + } +} + +export const librariesController = new LibrariesController(); diff --git a/src/controllers/maintenance.controller.ts b/src/controllers/maintenance.controller.ts new file mode 100644 index 0000000..2ed970e --- /dev/null +++ b/src/controllers/maintenance.controller.ts @@ -0,0 +1,88 @@ +/** + * Maintenance Controller + * + * Health check, maintenance mode toggle, scheduled maintenance windows, + * and backup list/create. Used by ops and super-admin. + * + * @module src/controllers/maintenance.controller + */ + +import { Request, Response } from 'express'; +import { maintenanceService } from '../services/maintenance.service'; + +export class MaintenanceController { + /** + * Performs a health check (e.g. DB connectivity) and returns system status. + * @param req - Express request + * @param res - Express response; sends status and timestamp + */ + async healthCheck(req: Request, res: Response): Promise { + try { + const isHealthy = await maintenanceService.healthCheck(); + res.json({ + status: isHealthy ? 'system healthy' : 'system unhealthy', + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + status: 'system unhealthy', + error: 'Health check failed', + timestamp: new Date().toISOString(), + }); + } + } + + async getMaintenanceStatus(req: Request, res: Response): Promise { + const status = await maintenanceService.getMaintenanceStatus(); + res.status(200).json(status); + } + + /** + * Enables or disables maintenance mode from body.enabled. + * @param req - Express request; body.enabled boolean + * @param res - Express response; sends updated maintenanceMode and message + */ + async toggleMaintenanceMode(req: Request, res: Response): Promise { + const { enabled } = req.body; + const maintenanceMode = await maintenanceService.toggleMaintenanceMode(enabled); + + res.status(200).json({ + success: true, + maintenanceMode, + message: `Maintenance mode ${enabled ? 'enabled' : 'disabled'}`, + }); + } + + async scheduleMaintenance(req: Request, res: Response): Promise { + const window = await maintenanceService.scheduleMaintenance(req.body); + res.status(201).json(window); + } + + /** + * Creates a backup of the given type (e.g. database, files, full). + * @param req - Express request; body.type + * @param res - Express response; sends 201 and backup record + */ + async createBackup(req: Request, res: Response): Promise { + const { type } = req.body; + const backup = await maintenanceService.createBackup(type); + res.status(201).json(backup); + } + + async getBackups(req: Request, res: Response): Promise { + const backups = await maintenanceService.getBackups(); + res.status(200).json(backups); + } + + /** + * Refreshes and returns current system health metrics. + * @param req - Express request + * @param res - Express response; sends systemHealth data + */ + async refreshSystemStatus(req: Request, res: Response): Promise { + const status = await maintenanceService.refreshSystemStatus(); + res.status(200).json(status); + } +} + +export const maintenanceController = new MaintenanceController(); diff --git a/src/controllers/media.controller.ts b/src/controllers/media.controller.ts new file mode 100644 index 0000000..8e92097 --- /dev/null +++ b/src/controllers/media.controller.ts @@ -0,0 +1,113 @@ +/** + * Media Controller + * + * List/get/create/update/delete media items. List supports libraryId, galleryId, + * mediaType, tags, approved, limit, offset. Create/update require auth and libraryId. + * + * @module src/controllers/media.controller + */ + +import { Request, Response } from 'express'; +import { mediaService } from '../services/media.service'; +import { AuthorizationError } from '../utils/errors'; + +export class MediaController { + /** + * Lists media items with optional filters: libraryId, galleryId, mediaType, tags, approved, limit, offset. + * @param req - Express request; query params for filters + * @param res - Express response; sends JSON array of media items + */ + async getMediaItems(req: Request, res: Response): Promise { + const libraryId = req.query.libraryId ? String(req.query.libraryId) : undefined; + const galleryId = req.query.galleryId ? String(req.query.galleryId) : undefined; + + // Handle boolean parameters properly + let approved = undefined; + if (req.query.approved !== undefined) { + approved = req.query.approved === 'true'; + } + + const mediaType = req.query.mediaType ? String(req.query.mediaType) : undefined; + const tags = req.query.tag + ? Array.isArray(req.query.tag) + ? (req.query.tag as string[]) + : [req.query.tag as string] + : undefined; + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const offset = req.query.offset ? Number(req.query.offset) : undefined; + + const media = await mediaService.getMediaItems({ + libraryId, + galleryId, + mediaType, + tags, + limit, + offset, + approved, + }); + + res.status(200).json(media); + } + + async getMediaItem(req: Request, res: Response): Promise { + const mediaId = req.params.id; + const mediaItem = await mediaService.getMediaItem(mediaId); + res.status(200).json(mediaItem); + } + + /** + * Creates a new media item for the session user's library; optional file upload. + * @param req - Express request; body + optional req.file + * @param res - Express response; sends 201 and created media item + */ + async createMediaItem(req: Request, res: Response): Promise { + if (!req.session.user) { + throw new AuthorizationError('Unauthorized - not logged in'); + } + const libraryId = req.session.user.libraryId; + if (!libraryId) { + throw new AuthorizationError('Library context required'); + } + const mediaItem = await mediaService.createMediaItem(req.body, libraryId, req.file); + + res.status(201).json(mediaItem); + } + + async updateMediaItem(req: Request, res: Response): Promise { + if (!req.session.user) { + throw new AuthorizationError('Unauthorized - not logged in'); + } + const libraryId = req.session.user.libraryId; + if (!libraryId) { + throw new AuthorizationError('Library context required'); + } + const mediaId = req.params.id; + const role = req.session.user.role; + + const updatedMedia = await mediaService.updateMediaItem( + mediaId, + req.body, + libraryId, + role, + req.file + ); + + res.status(200).json(updatedMedia); + } + + /** + * Returns all unique media tags (requires authenticated user). + * @param req - Express request + * @param res - Express response; sends JSON array of tag strings + */ + async getMediaTags(req: Request, res: Response): Promise { + if (!req.session.user) { + throw new AuthorizationError('Unauthorized - not logged in'); + } + + const tags = await mediaService.getMediaTags(); + res.status(200).json(tags); + } +} + +export const mediaController = new MediaController(); diff --git a/src/controllers/settings.controller.ts b/src/controllers/settings.controller.ts new file mode 100644 index 0000000..c007f87 --- /dev/null +++ b/src/controllers/settings.controller.ts @@ -0,0 +1,44 @@ +/** + * Settings Controller + * + * Global app settings: get, update, and test email configuration. + * + * @module src/controllers/settings.controller + */ + +import { Request, Response } from 'express'; +import { settingsService } from '../services/settings.service'; + +export class SettingsController { + /** + * Returns current platform settings. + * @param req - Express request + * @param res - Express response; sends settings object + */ + async getSettings(req: Request, res: Response): Promise { + const settings = await settingsService.getSettings(); + res.status(200).json(settings); + } + + /** + * Updates platform settings from body (merged with existing). + * @param req - Express request; body with partial settings + * @param res - Express response; sends updated settings + */ + async updateSettings(req: Request, res: Response): Promise { + const settings = await settingsService.updateSettings(req.body); + res.status(200).json(settings); + } + + /** + * Sends a test email to verify email configuration. + * @param req - Express request + * @param res - Express response; sends test result message + */ + async testEmail(req: Request, res: Response): Promise { + const result = await settingsService.testEmail(); + res.status(200).json(result); + } +} + +export const settingsController = new SettingsController(); diff --git a/src/controllers/stories.controller.ts b/src/controllers/stories.controller.ts new file mode 100644 index 0000000..1e9f422 --- /dev/null +++ b/src/controllers/stories.controller.ts @@ -0,0 +1,138 @@ +/** + * Stories Controller + * + * CRUD for stories and timelines. Create/update use session libraryId and + * optional file upload. List supports libraryId, published, approved, featured, + * tags, limit, offset. Also exposes story tags and timeline by story. + * + * @module src/controllers/stories.controller + */ + +import { Request, Response } from 'express'; +import { storiesService } from '../services/stories.service'; + +export class StoriesController { + /** + * Creates a new story for the session user's library; optional featured image upload. + * @param req - Express request; body + optional req.file + * @param res - Express response; sends 201 and created story + */ + async createStory(req: Request, res: Response): Promise { + const libraryId = req.session.user!.libraryId ?? ''; + const story = await storiesService.createStory(req.body, libraryId, req.file); + + res.status(201).json({ + success: true, + data: story, + }); + } + + /** + * Updates a story by ID; uses session libraryId and role. Optional featured image upload. + * @param req - Express request; params.id, body, optional req.file + * @param res - Express response; sends updated story + */ + async updateStory(req: Request, res: Response): Promise { + const storyId = req.params.id; + const libraryId = req.session.user!.libraryId ?? ''; + const role = req.session.user!.role ?? ''; + + const updatedStory = await storiesService.updateStory( + storyId, + req.body, + libraryId, + role, + req.file + ); + + res.status(200).json({ + success: true, + data: updatedStory, + }); + } + + async getStory(req: Request, res: Response): Promise { + const storyId = req.params.id; + const story = await storiesService.getStory(storyId); + + res.status(200).json({ + success: true, + data: story, + }); + } + + /** + * Lists stories with optional filters: libraryId, published, approved, featured, tags, limit, offset. + * @param req - Express request; query params for filters + * @param res - Express response; sends JSON array of stories + */ + async getStories(req: Request, res: Response): Promise { + // Extract query parameters + const libraryId = req.query.libraryId ? String(req.query.libraryId) : undefined; + + // Handle boolean parameters properly + let published = undefined; + if (req.query.published !== undefined) { + published = req.query.published === 'true'; + } + + let approved = undefined; + if (req.query.approved !== undefined) { + approved = req.query.approved === 'true'; + } + + let featured = undefined; + if (req.query.featured !== undefined) { + featured = req.query.featured === 'true'; + } + + const tags = req.query.tag + ? Array.isArray(req.query.tag) + ? (req.query.tag as string[]) + : [req.query.tag as string] + : undefined; + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const offset = req.query.offset ? Number(req.query.offset) : undefined; + + const stories = await storiesService.getStories({ + libraryId, + published, + approved, + featured, + tags, + limit, + offset, + }); + + res.status(200).json(stories); + } + + /** + * Returns all unique story tags. + * @param req - Express request + * @param res - Express response; sends JSON array of tag strings + */ + async getStoryTags(req: Request, res: Response): Promise { + const tags = await storiesService.getStoryTags(); + res.status(200).json(tags); + } + + async getTimelines(req: Request, res: Response): Promise { + const storyId = req.params.id; + const timelines = await storiesService.getTimelinesByStoryId(storyId); + res.status(200).json(timelines); + } + + /** + * Creates a new timeline for a story. + * @param req - Express request; params.id (storyId), body with timeline data + * @param res - Express response; sends created timeline + */ + async createTimeline(req: Request, res: Response): Promise { + const storyId = req.params.id; + const timeline = await storiesService.createTimeline(storyId, req.body); + res.status(200).json(timeline); + } +} + +export const storiesController = new StoriesController(); diff --git a/src/controllers/superadmin.controller.ts b/src/controllers/superadmin.controller.ts new file mode 100644 index 0000000..6f97ad9 --- /dev/null +++ b/src/controllers/superadmin.controller.ts @@ -0,0 +1,132 @@ +/** + * Super Admin Controller + * + * Platform-wide stats, pending stories/media approval, library and user + * management. All endpoints require super_admin role. + * + * @module src/controllers/superadmin.controller + */ + +import { Request, Response } from 'express'; +import { superAdminService } from '../services/superadmin.service'; + +export class SuperAdminController { + /** + * Returns platform-wide dashboard stats (libraries, stories, media, users, activity). + * @param req - Express request + * @param res - Express response; sends JSON stats + */ + async getStats(req: Request, res: Response): Promise { + const stats = await superAdminService.getStats(); + res.status(200).json(stats); + } + + /** + * Returns stories pending approval. + * @param req - Express request + * @param res - Express response; sends JSON array of stories + */ + async getPendingStories(req: Request, res: Response): Promise { + const stories = await superAdminService.getPendingStories(); + res.status(200).json(stories); + } + + /** + * Returns media items pending approval. + * @param req - Express request + * @param res - Express response; sends JSON array of media + */ + async getPendingMedia(req: Request, res: Response): Promise { + const media = await superAdminService.getPendingMedia(); + res.status(200).json(media); + } + + /** + * Approves a story by ID. + * @param req - Express request; params.id + * @param res - Express response; sends updated story + */ + async approveStory(req: Request, res: Response): Promise { + const storyId = req.params.id; + const story = await superAdminService.approveStory(storyId); + res.status(200).json(story); + } + + /** + * Rejects a story by ID (sets approved to false). + * @param req - Express request; params.id + * @param res - Express response; sends updated story + */ + async rejectStory(req: Request, res: Response): Promise { + const storyId = req.params.id; + const story = await superAdminService.rejectStory(storyId); + res.status(200).json(story); + } + + /** + * Approves a media item by ID. + * @param req - Express request; params.id + * @param res - Express response; sends updated media + */ + async approveMedia(req: Request, res: Response): Promise { + const mediaId = req.params.id; + const media = await superAdminService.approveMedia(mediaId); + res.status(200).json(media); + } + + /** + * Rejects a media item by ID (sets approved to false). + * @param req - Express request; params.id + * @param res - Express response; sends updated media + */ + async rejectMedia(req: Request, res: Response): Promise { + const mediaId = req.params.id; + const media = await superAdminService.rejectMedia(mediaId); + res.status(200).json(media); + } + + async getLibraries(req: Request, res: Response): Promise { + const libraries = await superAdminService.getLibraries(); + res.status(200).json(libraries); + } + + /** + * Returns all users across libraries. + * @param req - Express request + * @param res - Express response; sends JSON array of users + */ + async getUsers(req: Request, res: Response): Promise { + const users = await superAdminService.getUsers(); + res.status(200).json(users); + } + + /** + * Creates a new user from body (password hashed by service). + * @param req - Express request; body with user fields + * @param res - Express response; sends 201 and created user + */ + async createUser(req: Request, res: Response): Promise { + const user = await superAdminService.createUser(req.body); + res.status(201).json(user); + } + + async updateUser(req: Request, res: Response): Promise { + const userId = req.params.id; + const user = await superAdminService.updateUser(userId, req.body); + res.status(200).json(user); + } + + /** + * Resets a user's password by ID; body.password is hashed by service. + * @param req - Express request; params.id, body.password + * @param res - Express response; sends success message + */ + async resetUserPassword(req: Request, res: Response): Promise { + const userId = req.params.id; + const { password } = req.body; + await superAdminService.resetUserPassword(userId, password); + res.status(200).json({ message: 'Password reset successfully' }); + } +} + +export const superAdminController = new SuperAdminController(); diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 3abf148..8173fe2 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -1,26 +1,49 @@ +/** + * Authentication & Authorization Middlewares + * + * Protects routes by requiring a valid session and optionally specific roles. + * Use requireAuth for any authenticated route; use requireRole or the preset + * requireSuperAdmin / requireLibraryAdmin for role-based access. + * + * @module src/middlewares/auth + */ + import { Request, Response, NextFunction } from 'express'; import { AuthenticationError, AuthorizationError } from '../utils/errors'; +/** + * Ensures the request has an authenticated session. Passes AuthenticationError + * to next() if req.session or req.session.user is missing. + */ export const requireAuth = (req: Request, res: Response, next: NextFunction) => { - if (!req.session?.user) { - throw new AuthenticationError('Authentication required'); + if (!req.session || !req.session.user) { + return next(new AuthenticationError('Authentication required')); } next(); }; +/** + * Factory that returns a middleware requiring the user to have one of the given roles. + * Checks authentication first, then role. Passes AuthorizationError if role is not allowed. + * + * @param roles - Allowed roles (e.g. 'library_admin', 'super_admin') + */ export const requireRole = (...roles: string[]) => { return (req: Request, res: Response, next: NextFunction) => { - if (!req.session?.user) { - throw new AuthenticationError('Authentication required'); + if (!req.session || !req.session.user) { + return next(new AuthenticationError('Authentication required')); } if (!roles.includes(req.session.user.role)) { - throw new AuthorizationError(`Access denied. Required roles: ${roles.join(', ')}`); + return next(new AuthorizationError(`Access denied. Required roles: ${roles.join(', ')}`)); } next(); }; }; +/** Middleware that allows only super_admin role. */ export const requireSuperAdmin = requireRole('super_admin'); + +/** Middleware that allows library_admin or super_admin. */ export const requireLibraryAdmin = requireRole('library_admin', 'super_admin'); diff --git a/src/middlewares/error-handler.ts b/src/middlewares/error-handler.ts index a8e48be..a29dd90 100644 --- a/src/middlewares/error-handler.ts +++ b/src/middlewares/error-handler.ts @@ -1,16 +1,50 @@ +/** + * Global Error Handler Middleware + * + * Must be registered after all routes. Handles ZodErrors (400 + field errors), + * AppError subclasses (status + optional details), and unknown errors (500). + * Uses centralized api-response formatting; never leaks stack traces, internal + * systems, or IPs to the client in production. + * + * @module src/middlewares/error-handler + */ + import { Request, Response, NextFunction } from 'express'; import { ZodError } from 'zod'; import { AppError, ValidationError } from '../utils/errors'; import { env } from '../config/env'; import { logger } from './logger'; +import { + formatErrorResponse, + ErrorCode, + type ApiErrorResponse, +} from '../utils/api-response'; + +const isProduction = env.NODE_ENV === 'production'; +/** + * Sends a standardized error response. In production, never attaches stack or raw internal messages. + */ +function sendError( + res: Response, + statusCode: number, + body: ApiErrorResponse, + devExtra?: Record +): void { + const payload = isProduction ? body : { ...body, ...devExtra }; + res.status(statusCode).json(payload); +} + +/** + * Central error handler. Logs the error, then sends a JSON response with + * success: false, error (sanitized), optional code/errors, and timestamp. + */ export const errorHandler = ( err: Error | AppError | ZodError, req: Request, res: Response, next: NextFunction ): void => { - // Log error logger.error('Error occurred', { error: err.message, stack: err.stack, @@ -20,7 +54,7 @@ export const errorHandler = ( userAgent: req.get('user-agent'), }); - // Handle Zod validation errors + // Zod validation errors if (err instanceof ZodError) { const formattedErrors = err.errors.reduce((acc, error) => { const path = error.path.join('.'); @@ -31,42 +65,40 @@ export const errorHandler = ( return acc; }, {} as Record); - res.status(400).json({ - success: false, + const { body } = formatErrorResponse({ + statusCode: 400, error: 'Validation failed', + code: ErrorCode.VALIDATION_ERROR, errors: formattedErrors, - timestamp: new Date().toISOString(), + isProduction, }); + sendError(res, 400, body); return; } - // Handle custom AppError + // Custom AppError if (err instanceof AppError) { - const response: any = { - success: false, + const { statusCode, body } = formatErrorResponse({ + statusCode: err.statusCode, error: err.message, - code: err.code, - timestamp: new Date().toISOString(), - }; - - if (err instanceof ValidationError && err.errors) { - response.errors = err.errors; - } - - if (env.NODE_ENV === 'development') { - response.stack = err.stack; - } - - res.status(err.statusCode).json(response); + code: err.code as import('../utils/api-response').ErrorCodeType | undefined, + errors: err instanceof ValidationError ? err.errors : undefined, + isProduction, + rawMessage: err.message, + }); + sendError(res, statusCode, body, isProduction ? undefined : { stack: err.stack }); return; } - // Handle unexpected errors - res.status(500).json({ - success: false, - error: 'Internal server error', - message: env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred', - timestamp: new Date().toISOString(), - ...(env.NODE_ENV === 'development' && { stack: err.stack }), + // Unexpected errors: generic message in production, no stack or raw message leak + const { body } = formatErrorResponse({ + statusCode: 500, + error: 'An unexpected error occurred. Please try again or contact support.', + code: ErrorCode.INTERNAL_ERROR, + isProduction, + rawMessage: err.message, }); + sendError(res, 500, body, isProduction ? undefined : { stack: err.stack }); }; + +export default errorHandler; diff --git a/src/middlewares/logger.ts b/src/middlewares/logger.ts index 2d72869..a16c666 100644 --- a/src/middlewares/logger.ts +++ b/src/middlewares/logger.ts @@ -1,3 +1,13 @@ +/** + * Application Logger + * + * Winston logger with file transports (error.log, combined.log) and optional + * console output in non-production. Also exports requestLogger middleware for + * structured HTTP request logging. + * + * @module src/middlewares/logger + */ + import { createLogger, format, transports } from 'winston'; import { env } from '../config/env'; @@ -36,7 +46,10 @@ if (env.NODE_ENV !== 'production') { ); } -// Request logging middleware +/** + * Express middleware that logs each request (method, path, status, duration, IP, user-agent) + * and optionally the response body for paths under /api. + */ export const requestLogger = (req: any, res: any, next: any) => { const start = Date.now(); const path = req.path; diff --git a/middlewares/rate-limiters.ts b/src/middlewares/rate-limiters.ts similarity index 74% rename from middlewares/rate-limiters.ts rename to src/middlewares/rate-limiters.ts index 18c178c..3792d45 100644 --- a/middlewares/rate-limiters.ts +++ b/src/middlewares/rate-limiters.ts @@ -1,28 +1,42 @@ +/** + * API Rate Limiters + * + * Preconfigured express-rate-limit instances for different route groups. + * Uses centralized error response format (success, error, code, timestamp). + * + * @module src/middlewares/rate-limiters + */ + import rateLimit from 'express-rate-limit'; import { Request, Response } from 'express'; +import { formatErrorResponse, ErrorCode } from '../utils/api-response'; -// Extend Express Request type to include rateLimit property declare module 'express' { interface Request { rateLimit?: { resetTime?: number; - [key: string]: any; + [key: string]: unknown; }; } } -// Helper function to create custom error messages +const isProduction = process.env.NODE_ENV === 'production'; + +/** Returns a 429 handler using the shared error response format. */ const createLimitHandler = (message: string) => { return (req: Request, res: Response) => { - res.status(429).json({ - error: 'Too Many Requests', - message, - retryAfter: Math.round((req as any).rateLimit?.resetTime / 1000) || 60 + const retryAfter = Math.round((req as any).rateLimit?.resetTime / 1000) || 60; + const { statusCode, body } = formatErrorResponse({ + statusCode: 429, + error: message, + code: ErrorCode.RATE_LIMITED, + isProduction, }); + res.status(statusCode).json({ ...body, retryAfter }); }; }; -// General API rate limiter - 100 requests per 15 minutes +/** 100 requests per 15 minutes per IP for general API. */ export const generalApiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per windowMs @@ -32,7 +46,7 @@ export const generalApiLimiter = rateLimit({ handler: createLimitHandler('Too many API requests, please try again in 15 minutes.') }); -// Strict limiter for authentication endpoints - 5 attempts per 15 minutes +/** 5 attempts per 15 minutes per IP for auth; successful requests not counted. */ export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // limit each IP to 5 login requests per windowMs @@ -43,7 +57,7 @@ export const authLimiter = rateLimit({ handler: createLimitHandler('Too many login attempts. Please try again in 15 minutes.') }); -// Contact form limiter - 3 submissions per hour +/** 3 contact form submissions per hour per IP. */ export const contactLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 3, // limit each IP to 3 contact form submissions per hour @@ -53,7 +67,7 @@ export const contactLimiter = rateLimit({ handler: createLimitHandler('Too many contact form submissions. Please try again in 1 hour.') }); -// File upload limiter - 10 uploads per hour +/** 10 file uploads per hour per IP. */ export const uploadLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 10, // limit each IP to 10 file uploads per hour @@ -63,7 +77,7 @@ export const uploadLimiter = rateLimit({ handler: createLimitHandler('Too many file uploads. Please try again in 1 hour.') }); -// Admin actions limiter - 200 requests per hour +/** 200 admin requests per hour per IP. */ export const adminLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 200, // limit each IP to 200 admin requests per hour @@ -73,7 +87,7 @@ export const adminLimiter = rateLimit({ handler: createLimitHandler('Too many admin actions. Please try again in 1 hour.') }); -// Public content limiter - 500 requests per hour (more generous for browsing) +/** 500 requests per hour per IP for public content (libraries, stories, events, media). */ export const publicLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 500, // limit each IP to 500 public requests per hour @@ -83,7 +97,7 @@ export const publicLimiter = rateLimit({ handler: createLimitHandler('Too many requests. Please try again in 1 hour.') }); -// Password reset limiter - 3 attempts per hour +/** 3 password reset attempts per hour per IP. */ export const passwordResetLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 3, // limit each IP to 3 password reset requests per hour @@ -93,7 +107,7 @@ export const passwordResetLimiter = rateLimit({ handler: createLimitHandler('Too many password reset attempts. Please try again in 1 hour.') }); -// Email sending limiter - 10 emails per hour +/** 10 email sends per hour per IP. */ export const emailLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 10, // limit each IP to 10 email sends per hour @@ -103,7 +117,7 @@ export const emailLimiter = rateLimit({ handler: createLimitHandler('Too many emails sent. Please try again in 1 hour.') }); -// Search limiter - 100 searches per 15 minutes +/** 100 search requests per 15 minutes per IP. */ export const searchLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 search requests per windowMs @@ -111,4 +125,4 @@ export const searchLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, handler: createLimitHandler('Too many search requests. Please try again in 15 minutes.') -}); \ No newline at end of file +}); diff --git a/src/middlewares/validation.ts b/src/middlewares/validation.ts index 6caae42..0a2b15d 100644 --- a/src/middlewares/validation.ts +++ b/src/middlewares/validation.ts @@ -1,7 +1,21 @@ +/** + * Request Validation Middlewares + * + * Uses Zod schemas to validate req.body, req.query, or req.params. On failure, + * passes a ValidationError to next() with field-level error details for + * consistent API error responses. + * + * @module src/middlewares/validation + */ + import { Request, Response, NextFunction } from 'express'; import { ZodSchema, ZodError } from 'zod'; import { ValidationError } from '../utils/errors'; +/** + * Returns a middleware that validates req.body against the given Zod schema. + * Parsed body is not mutated; use schema.parse() in the route if you need the coerced value. + */ export const validate = (schema: ZodSchema) => { return (req: Request, res: Response, next: NextFunction) => { try { @@ -18,9 +32,9 @@ export const validate = (schema: ZodSchema) => { return acc; }, {} as Record); - throw new ValidationError('Validation failed', formattedErrors); + return next(new ValidationError('Validation failed', formattedErrors)); } - next(error); + return next(error); } }; }; @@ -41,13 +55,16 @@ export const validateQuery = (schema: ZodSchema) => { return acc; }, {} as Record); - throw new ValidationError('Query validation failed', formattedErrors); + return next(new ValidationError('Query validation failed', formattedErrors)); } - next(error); + return next(error); } }; }; +/** + * Returns a middleware that validates req.params against the given Zod schema. + */ export const validateParams = (schema: ZodSchema) => { return (req: Request, res: Response, next: NextFunction) => { try { @@ -64,9 +81,9 @@ export const validateParams = (schema: ZodSchema) => { return acc; }, {} as Record); - throw new ValidationError('Parameter validation failed', formattedErrors); + return next(new ValidationError('Parameter validation failed', formattedErrors)); } - next(error); + return next(error); } }; }; diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 3d1f1ad..32eaa47 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -1,10 +1,26 @@ +/** + * Admin Routes + * + * Library-admin dashboard: stats, analytics, activity, galleries, image delete. + * All endpoints require requireAuth; libraryId is taken from session. + * + * @module src/routes/admin.routes + */ + import type { Express } from "express"; -import drizzleService from "../../services/drizzle-services"; +import drizzleService from "../services/drizzle-services"; +import { requireAuth } from "../middlewares/auth"; import { apiHandler } from "./shared"; - +import { sendApiError, ErrorCode } from "../utils/api-response"; + +/** + * Registers admin routes: dashboard stats, analytics, activity, galleries, and image delete. + * All routes use requireAuth and session libraryId where applicable. + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerAdminRoutes(app: Express, global_path: string) { - // Analytics endpoints - app.get(`${global_path}/admin/dashboard/stats`, async (req, res) => { + app.get(`${global_path}/admin/dashboard/stats`, requireAuth, async (req, res) => { try { const libraryId = req.session.user?.libraryId; if (!libraryId) { @@ -34,7 +50,7 @@ export function registerAdminRoutes(app: Express, global_path: string) { } }); - app.get(`${global_path}/admin/dashboard/analytics`, async (req, res) => { + app.get(`${global_path}/admin/dashboard/analytics`, requireAuth, async (req, res) => { try { const libraryId = req.session.user?.libraryId; if (!libraryId) { @@ -96,7 +112,7 @@ export function registerAdminRoutes(app: Express, global_path: string) { } }); - app.get(`${global_path}/admin/dashboard/activity`, async (req, res) => { + app.get(`${global_path}/admin/dashboard/activity`, requireAuth, async (req, res) => { try { const libraryId = req.session.user?.libraryId; if (!libraryId) { @@ -147,7 +163,7 @@ export function registerAdminRoutes(app: Express, global_path: string) { })); // Delete Image Route - app.delete(global_path + '/admin/upload/image/:publicId', apiHandler(async (req, res) => { + app.delete(global_path + '/admin/upload/image/:publicId', requireAuth, apiHandler(async (req, res) => { const { publicId } = req.params; const { cloudinaryService } = await import("../../config/bucket-storage/cloudinary"); @@ -167,10 +183,7 @@ export function registerAdminRoutes(app: Express, global_path: string) { } } catch (error) { console.error("Image deletion error:", error); - return res.status(500).json({ - error: 'Failed to delete image', - message: error instanceof Error ? error.message : String(error) - }); + return sendApiError(res, 500, 'Failed to delete image. Please try again or contact support.', ErrorCode.INTERNAL_ERROR); } })); } diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 9925f4b..d0541d4 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -1,13 +1,26 @@ +/** + * Auth Routes + * + * Login (rate-limited, validated), session get, and logout. Uses in-route + * logic and drizzleService; consider migrating to AuthController for consistency. + * + * @module src/routes/auth.routes + */ + import type { Express, Request, Response, NextFunction } from "express"; import { compare } from "bcrypt"; -import drizzleService from "../../services/drizzle-services"; -import { validate } from "../../utils/validations"; +import drizzleService from "../services/drizzle-services"; +import { validate } from "../middlewares/validation"; import { loginSchema } from "../validations/auth.schemas"; import { AuthenticationError } from "../utils/errors"; -import { authLimiter } from '../../middlewares/rate-limiters'; +import { authLimiter } from '../middlewares/rate-limiters'; +/** + * Registers auth routes: POST login (rate-limited, validated), GET session, POST logout. + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerAuthRoutes(app: Express, global_path: string) { - // Authentication routes app.post(`${global_path}/auth/login`, authLimiter, validate(loginSchema), async (req, res, next) => { try { const { username, password } = req.body; diff --git a/src/routes/contact.routes.ts b/src/routes/contact.routes.ts index ef1f41a..a6d72ad 100644 --- a/src/routes/contact.routes.ts +++ b/src/routes/contact.routes.ts @@ -1,12 +1,26 @@ -import type { Express } from "express"; -import drizzleService from "../../services/drizzle-services"; -import { sendResponseEmail } from "../../services/email-service"; -import { contactLimiter, emailLimiter } from '../../middlewares/rate-limiters'; -import { jsonApiMiddleware, apiHandler } from "./shared"; +/** + * Contact Routes + * + * Contact form submit (rate-limited) and admin: list messages, update, reply. + * Reply uses email limiter and requireLibraryAdmin. + * + * @module src/routes/contact.routes + */ +import type { Express } from "express"; +import drizzleService from "../services/drizzle-services"; +import { sendResponseEmail } from "../services/email-service"; +import { contactLimiter, emailLimiter } from '../middlewares/rate-limiters'; +import { requireAuth, requireLibraryAdmin } from "../middlewares/auth"; +import { apiHandler } from "./shared"; + +/** + * Registers contact routes: list/get/patch/delete messages (admin), POST message (public, rate-limited), POST reply (library admin, email rate-limited). + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerContactRoutes(app: Express, global_path: string) { - // Contact messages endpoints - app.get(`${global_path}/contact-messages`, async (req, res) => { + app.get(`${global_path}/contact-messages`, requireAuth, requireLibraryAdmin, async (req, res) => { try { const libraryId = req.session.user?.libraryId; const options = libraryId ? { libraryId } : {}; @@ -29,7 +43,23 @@ export function registerContactRoutes(app: Express, global_path: string) { } }); - app.patch(`${global_path}/contact-messages/:id`, async (req, res) => { + app.get(`${global_path}/contact-messages/:id`, requireAuth, requireLibraryAdmin, async (req, res) => { + try { + const messageId = req.params.id; + const message = await drizzleService.getContactMessage(messageId); + + if (!message || (req.session.user?.libraryId && message.libraryId !== req.session.user.libraryId)) { + return res.status(404).json({ error: 'Contact message not found' }); + } + + return res.status(200).json(message); + } catch (error) { + console.error("Error fetching contact message:", error); + return res.status(500).json({ error: 'Internal server error' }); + } + }); + + app.patch(`${global_path}/contact-messages/:id`, requireAuth, requireLibraryAdmin, async (req, res) => { try { const messageId = req.params.id; const updatedMessage = await drizzleService.updateContactMessage(messageId, req.body); @@ -45,12 +75,29 @@ export function registerContactRoutes(app: Express, global_path: string) { } }); - // Reply to a contact message - app.post(`${global_path}/contact-messages/:id/reply`, emailLimiter, jsonApiMiddleware, apiHandler(async (req, res) => { - if (!req.session.user || req.session.user.role !== 'library_admin') { - return res.status(403).json({ error: "Unauthorized" }); + app.delete(`${global_path}/contact-messages/:id`, requireAuth, requireLibraryAdmin, async (req, res) => { + try { + const messageId = req.params.id; + const message = await drizzleService.getContactMessage(messageId); + + if (!message || (req.session.user?.libraryId && message.libraryId !== req.session.user.libraryId)) { + return res.status(404).json({ error: 'Contact message not found' }); + } + + const deleted = await drizzleService.deleteContactMessage(messageId); + if (!deleted) { + return res.status(404).json({ error: 'Contact message not found' }); + } + + return res.status(204).send(); + } catch (error) { + console.error("Error deleting contact message:", error); + return res.status(500).json({ error: 'Internal server error' }); } + }); + // Reply to a contact message + app.post(`${global_path}/contact-messages/:id/reply`, requireAuth, requireLibraryAdmin, emailLimiter, apiHandler(async (req, res) => { const messageId = req.params.id; const { subject, message } = req.body; @@ -60,12 +107,16 @@ export function registerContactRoutes(app: Express, global_path: string) { // Get the original message const originalMessage = await drizzleService.getContactMessage(messageId); - if (!originalMessage || originalMessage.libraryId !== req.session.user.libraryId) { + if (!originalMessage || originalMessage.libraryId !== req.session!.user!.libraryId) { return res.status(404).json({ error: "Message not found" }); } // Get library information - const library = await drizzleService.getLibrary(req.session.user.libraryId!); + const libraryId = req.session?.user?.libraryId; + if (!libraryId) { + return res.status(401).json({ error: "Unauthorized" }); + } + const library = await drizzleService.getLibrary(libraryId); if (!library) { return res.status(404).json({ error: "Library not found" }); } @@ -89,7 +140,7 @@ export function registerContactRoutes(app: Express, global_path: string) { // Create message response record const response = await drizzleService.createMessageResponse({ contactMessageId: messageId, - respondedBy: req.session.user.id, + respondedBy: req.session!.user!.id, subject, message }); diff --git a/src/routes/events.routes.ts b/src/routes/events.routes.ts index a1e725a..f012b5f 100644 --- a/src/routes/events.routes.ts +++ b/src/routes/events.routes.ts @@ -1,14 +1,25 @@ +/** + * Events Routes + * + * CRUD for events. Create/update use requireAuth, session libraryId, and + * optional event image upload. List and delete scoped by library or id. + * + * @module src/routes/events.routes + */ + import type { Express } from "express"; -import drizzleService from "../../services/drizzle-services"; +import drizzleService from "../services/drizzle-services"; +import { requireAuth } from "../middlewares/auth"; import { upload, apiHandler, uploadImageToCloudinary } from "./shared"; +/** + * Registers events routes: POST/PATCH/GET/DELETE events; create/update use requireAuth and optional image upload. + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerEventsRoutes(app: Express, global_path: string) { - app.post(`${global_path}/events`, upload.single('eventImage'), apiHandler(async (req, res) => { - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - - const libraryId = req.session.user.libraryId; + app.post(`${global_path}/events`, requireAuth, upload.single('eventImage'), apiHandler(async (req, res) => { + const libraryId = req.session!.user!.libraryId; if (!libraryId) { return res.status(400).json({ error: 'Library ID required' }); } @@ -36,11 +47,7 @@ export function registerEventsRoutes(app: Express, global_path: string) { })); // Update event with image upload - app.patch(`${global_path}/events/:id`, upload.single('eventImage'), apiHandler(async (req, res) => { - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - + app.patch(`${global_path}/events/:id`, requireAuth, upload.single('eventImage'), apiHandler(async (req, res) => { const eventId = req.params.id; const existingEvent = await drizzleService.getEvent(eventId); @@ -48,8 +55,8 @@ export function registerEventsRoutes(app: Express, global_path: string) { return res.status(404).json({ error: 'Event not found' }); } - // Check ownership - if (req.session.user.role === 'library_admin' && existingEvent.libraryId !== req.session.user.libraryId) { + // Check ownership for library admins + if (req.session!.user!.role === 'library_admin' && existingEvent.libraryId !== req.session!.user!.libraryId) { return res.status(403).json({ error: 'Unauthorized - you can only edit events for your library' }); } @@ -73,7 +80,24 @@ export function registerEventsRoutes(app: Express, global_path: string) { return res.status(200).json(updatedEvent); })); - // Events endpoints + // Get single event + app.get(`${global_path}/events/:id`, async (req, res) => { + try { + const eventId = req.params.id; + const event = await drizzleService.getEvent(eventId); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + return res.status(200).json(event); + } catch (error) { + console.error(`Error fetching event with ID ${req.params.id}:`, error); + return res.status(500).json({ error: 'Internal server error' }); + } + }); + + // Events list endpoint app.get(`${global_path}/events`, async (req, res) => { try { const libraryId = req.session.user?.libraryId; @@ -87,19 +111,23 @@ export function registerEventsRoutes(app: Express, global_path: string) { } }); - app.delete(`${global_path}/events/:id`, async (req, res) => { - try { - const eventId = req.params.id; - const deleted = await drizzleService.deleteEvent(eventId); + app.delete(`${global_path}/events/:id`, requireAuth, apiHandler(async (req, res) => { + const eventId = req.params.id; + const existingEvent = await drizzleService.getEvent(eventId); - if (!deleted) { - return res.status(404).json({ error: 'Event not found' }); - } + if (!existingEvent) { + return res.status(404).json({ error: 'Event not found' }); + } - return res.status(200).json({ success: true }); - } catch (error) { - console.error("Error deleting event:", error); - return res.status(500).json({ error: 'Internal server error' }); + if (req.session!.user!.role === 'library_admin' && existingEvent.libraryId !== req.session!.user!.libraryId) { + return res.status(403).json({ error: 'Unauthorized - you can only delete events for your library' }); } - }); + + const deleted = await drizzleService.deleteEvent(eventId); + if (!deleted) { + return res.status(404).json({ error: 'Event not found' }); + } + + return res.status(204).send(); + })); } diff --git a/src/routes/index.ts b/src/routes/index.ts index 8335b11..e3bd6ef 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,15 @@ +/** + * API Route Registration + * + * Mounts all feature routes under a global path, applies JSON API and rate-limit + * middlewares, and returns the HTTP server instance. Rate limits are applied + * per-route type (auth, admin, public, search, etc.). + * + * @module src/routes/index + */ + import type { Express } from "express"; +import { createServer, type Server } from "http"; import { registerAuthRoutes } from "./auth.routes"; import { registerStoriesRoutes } from "./stories.routes"; import { registerLibrariesRoutes } from "./libraries.routes"; @@ -9,8 +20,52 @@ import { registerSuperAdminRoutes } from "./superadmin.routes"; import { registerContactRoutes } from "./contact.routes"; import { registerMaintenanceRoutes } from "./maintenance.routes"; import { registerSettingsRoutes } from "./settings.routes"; +import { + generalApiLimiter, + authLimiter, + contactLimiter, + uploadLimiter, + adminLimiter, + publicLimiter, + emailLimiter, + searchLimiter +} from '../middlewares/rate-limiters'; +import { jsonApiMiddleware } from "./shared"; + +/** + * Registers all API routes and rate limiters, then creates the HTTP server. + * + * @param global_path - Base path for all API routes (e.g. "/api/v1") + * @param app - Express application instance + * @returns HTTP server (not yet listening) + */ +export async function registerRoutes(global_path: string, app: Express): Promise { + app.use(global_path, jsonApiMiddleware); + app.use(global_path, generalApiLimiter); + + app.use(global_path + '/libraries', publicLimiter); + app.use(global_path + '/stories', publicLimiter); + app.use(global_path + '/events', publicLimiter); + app.use(global_path + '/media-items', publicLimiter); + + // Apply stricter rate limiting to admin routes + app.use(global_path + '/admin', adminLimiter); + + // Apply search rate limiting to search endpoints + app.get(global_path + '/libraries', (req, res, next) => { + if (req.query.search) { + return searchLimiter(req, res, next); + } + next(); + }); + + app.get(global_path + '/stories', (req, res, next) => { + if (req.query.search) { + return searchLimiter(req, res, next); + } + next(); + }); -export function registerAllRoutes(app: Express, global_path: string) { // Register all route modules registerAuthRoutes(app, global_path); registerStoriesRoutes(app, global_path); @@ -22,4 +77,7 @@ export function registerAllRoutes(app: Express, global_path: string) { registerContactRoutes(app, global_path); registerMaintenanceRoutes(app, global_path); registerSettingsRoutes(app, global_path); + + const httpServer = createServer(app); + return httpServer; } diff --git a/src/routes/libraries.routes.ts b/src/routes/libraries.routes.ts index 89ecbc8..1525692 100644 --- a/src/routes/libraries.routes.ts +++ b/src/routes/libraries.routes.ts @@ -1,9 +1,23 @@ +/** + * Libraries Routes + * + * Create library (requireSuperAdmin, multipart logo/featuredImage). Update/delete + * requireLibraryAdmin and session libraryId. List and get by ID are public. + * + * @module src/routes/libraries.routes + */ + import type { Express } from "express"; -import drizzleService from "../../services/drizzle-services"; +import drizzleService from "../services/drizzle-services"; import { NotFoundError, AuthorizationError } from "../utils/errors"; -import { requireSuperAdmin, requireLibraryAdmin } from "../../middlewares/auth"; +import { requireSuperAdmin, requireLibraryAdmin } from "../middlewares/auth"; import { upload, apiHandler, uploadImageToCloudinary } from "./shared"; +/** + * Registers libraries routes: POST create (super admin, multipart), PATCH/GET by id, GET list (public). + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerLibrariesRoutes(app: Express, global_path: string) { app.post(`${global_path}/libraries`, requireSuperAdmin, upload.fields([ { name: 'logo', maxCount: 1 }, diff --git a/src/routes/maintenance.routes.ts b/src/routes/maintenance.routes.ts index 9c7ae0d..736423e 100644 --- a/src/routes/maintenance.routes.ts +++ b/src/routes/maintenance.routes.ts @@ -1,7 +1,16 @@ +/** + * Maintenance Routes + * + * Health check, maintenance mode toggle, schedule windows, backups (list/create). + * State is in-memory; in production should be persisted (e.g. DB/Redis). + * + * @module src/routes/maintenance.routes + */ + import type { Express } from "express"; -import drizzleService from "../../services/drizzle-services"; +import drizzleService from "../services/drizzle-services"; -// Maintenance state (in production, this should be in a database or Redis) +/** In-memory maintenance state; prefer DB/Redis in production. */ let maintenanceMode = false; const maintenanceWindows: any[] = []; const backupHistory: any[] = [ @@ -11,6 +20,11 @@ const backupHistory: any[] = [ { id: 4, type: 'database', size: '885 MB', created: new Date('2025-06-15T02:00:00Z'), status: 'completed' }, ]; +/** + * Registers maintenance routes: health, status, toggle, schedule, backup list/create, refresh. + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerMaintenanceRoutes(app: Express, global_path: string) { // health check endpoint app.get(`${global_path}/health`, async (req, res) => { diff --git a/src/routes/media.routes.ts b/src/routes/media.routes.ts index 2f44ded..f6b0ec0 100644 --- a/src/routes/media.routes.ts +++ b/src/routes/media.routes.ts @@ -1,12 +1,25 @@ +/** + * Media Routes + * + * List/get/create/update/delete media items. List supports libraryId, galleryId, + * mediaType, tags, approved, limit, offset. Create/update use requireAuth and + * optional file upload. + * + * @module src/routes/media.routes + */ + import type { Express } from "express"; -import drizzleService from "../../services/drizzle-services"; +import drizzleService from "../services/drizzle-services"; import { upload, apiHandler, uploadImageToCloudinary } from "./shared"; +/** + * Registers media routes: list/get/create/update/delete media items; admin tags. Create/update use requireAuth and optional file upload. + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerMediaRoutes(app: Express, global_path: string) { - // Media endpoints app.get(`${global_path}/media-items`, async (req, res) => { try { - // Extract query parameters const libraryId = req.query.libraryId ? String(req.query.libraryId) : undefined; const galleryId = req.query.galleryId ? String(req.query.galleryId) : undefined; @@ -130,6 +143,31 @@ export function registerMediaRoutes(app: Express, global_path: string) { return res.status(200).json(updatedMedia); })); + // Delete media item + app.delete(`${global_path}/media-items/:id`, apiHandler(async (req, res) => { + if (!req.session?.user) { + return res.status(403).json({ error: 'Unauthorized - not logged in' }); + } + + const mediaId = req.params.id; + const existingMedia = await drizzleService.getMediaItem(mediaId); + + if (!existingMedia) { + return res.status(404).json({ error: 'Media item not found' }); + } + + if (req.session.user.role === 'library_admin' && existingMedia.libraryId !== req.session.user.libraryId) { + return res.status(403).json({ error: 'Unauthorized - you can only delete media for your library' }); + } + + const deleted = await drizzleService.deleteMediaItem(mediaId); + if (!deleted) { + return res.status(404).json({ error: 'Media item not found' }); + } + + return res.status(204).send(); + })); + // Admin: Get all unique media tags app.get(`${global_path}/admin/media/tags`, apiHandler(async (req, res) => { if (!req.session.user) { diff --git a/src/routes/settings.routes.ts b/src/routes/settings.routes.ts index 0e3afa2..140b81a 100644 --- a/src/routes/settings.routes.ts +++ b/src/routes/settings.routes.ts @@ -1,6 +1,15 @@ +/** + * Settings Routes + * + * Get/update platform settings and test email. State is in-memory; in + * production should be persisted (e.g. database). + * + * @module src/routes/settings.routes + */ + import type { Express } from "express"; -// Settings state (in production, this should be in a database) +/** In-memory platform settings; prefer database in production. */ let platformSettings = { general: { siteName: "Library Digital Platform", @@ -56,6 +65,11 @@ let platformSettings = { } }; +/** + * Registers settings routes: GET/POST settings, POST test-email. + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerSettingsRoutes(app: Express, global_path: string) { // Get platform settings app.get(`${global_path}/settings`, async (req, res) => { diff --git a/src/routes/shared.ts b/src/routes/shared.ts index 47e51f7..094f104 100644 --- a/src/routes/shared.ts +++ b/src/routes/shared.ts @@ -1,8 +1,19 @@ +/** + * Shared Route Utilities + * + * Multer config for in-memory uploads, Cloudinary upload helper, API handler + * wrapper for async route handlers, and JSON API middleware for consistent + * Content-Type and response shape. + * + * @module src/routes/shared + */ + import type { Request, Response, NextFunction } from "express"; import multer from 'multer'; import { cloudinaryService } from "../../config/bucket-storage/cloudinary"; +import { formatErrorResponse, ErrorCode } from "../utils/api-response"; -// Configure multer for memory storage +/** Multer instance: memory storage, 10MB max, image MIME types only. */ export const upload = multer({ storage: multer.memoryStorage(), limits: { @@ -18,7 +29,14 @@ export const upload = multer({ } }); -// Helper function to upload image to Cloudinary +/** + * Uploads a multipart file to Cloudinary under the given folder. + * + * @param file - Multer file (buffer + mimetype) + * @param folder - Cloudinary folder path (e.g. 'stories', 'libraries') + * @returns Public URL of the uploaded image, or null if Cloudinary is not configured + * @throws Error if Cloudinary is not configured or upload fails + */ export async function uploadImageToCloudinary(file: Express.Multer.File, folder: string): Promise { if (!cloudinaryService.isReady()) { throw new Error('Cloudinary not configured'); @@ -36,30 +54,31 @@ export async function uploadImageToCloudinary(file: Express.Multer.File, folder: } } -// API wrapper to ensure JSON responses -export function apiHandler(handler: (req: Request, res: Response) => Promise) { +/** + * Wraps an async route handler: sets Content-Type to application/json and + * forwards any thrown error to next() for the global error handler. + * + * @param handler - Async (req, res) => Promise (return value is ignored) + * @returns Express request handler + */ +export function apiHandler(handler: (req: Request, res: Response) => Promise) { return async (req: Request, res: Response, next: NextFunction) => { - // Always set JSON content type - res.setHeader('Content-Type', 'application/json'); + res.setHeader("Content-Type", "application/json"); try { await handler(req, res); } catch (error) { - console.error("API Error:", error); - res.status(500).json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error) - }); + next(error); } }; } -// Create a middleware to ensure all API responses are JSON +/** + * Middleware that sets Content-Type to application/json and overrides res.send + * so non-JSON strings are wrapped in { message: string } for consistent API responses. + */ export const jsonApiMiddleware = (req: Request, res: Response, next: NextFunction) => { - // Set the content type before any response is sent res.setHeader('Content-Type', 'application/json'); - - // Store the original res.send method const originalSend = res.send; // Override the send method to always ensure proper JSON responses @@ -72,7 +91,14 @@ export const jsonApiMiddleware = (req: Request, res: Response, next: NextFunctio return originalSend.call(this, body); } catch (error) { console.error("Error in JSON middleware:", error); - return originalSend.call(this, JSON.stringify({ error: "Internal server error" })); + const isProduction = process.env.NODE_ENV === "production"; + const { body } = formatErrorResponse({ + statusCode: 500, + error: "An unexpected error occurred. Please try again or contact support.", + code: ErrorCode.INTERNAL_ERROR, + isProduction, + }); + return originalSend.call(this, JSON.stringify(body)); } }; diff --git a/src/routes/stories.routes.ts b/src/routes/stories.routes.ts index 0493069..e117e64 100644 --- a/src/routes/stories.routes.ts +++ b/src/routes/stories.routes.ts @@ -1,12 +1,25 @@ +/** + * Stories Routes + * + * Public: GET stories (list), GET story by id, GET tags, GET timelines by story. + * Admin: POST/PUT/DELETE stories, POST timeline. Uses requireAuth and optional + * file upload for featured image. + * + * @module src/routes/stories.routes + */ + import type { Express, Request, Response, NextFunction } from "express"; -import drizzleService from "../../services/drizzle-services"; -import { Story } from "../../config/database/schema"; -import { NotFoundError } from "../utils/errors"; -import { requireAuth } from "../../middlewares/auth"; +import drizzleService from "../services/drizzle-services"; +import { NotFoundError, AuthorizationError } from "../utils/errors"; +import { requireAuth } from "../middlewares/auth"; import { upload, apiHandler, uploadImageToCloudinary } from "./shared"; +/** + * Registers stories routes: public list/get/tags/timelines; admin create/update/delete stories and timelines (requireAuth, optional file upload). + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerStoriesRoutes(app: Express, global_path: string) { - // Admin story management endpoints app.post(`${global_path}/admin/stories`, requireAuth, upload.single('featuredImage'), apiHandler(async (req, res) => { const libraryId = req.session.user!.libraryId; @@ -47,7 +60,7 @@ export function registerStoriesRoutes(app: Express, global_path: string) { // Check ownership for library admins if (req.session.user!.role === 'library_admin' && existingStory.libraryId !== libraryId) { - throw new Error('You can only edit stories for your library'); + throw new AuthorizationError('You can only edit stories for your library'); } // Handle featured image upload @@ -86,42 +99,62 @@ export function registerStoriesRoutes(app: Express, global_path: string) { }); })); - // Admin timelines endpoints - app.get(`${global_path}/admin/stories/:id/timelines`, apiHandler(async (req, res) => { - // Relaxed authentication for testing - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); + // Admin delete story endpoint + app.delete(`${global_path}/admin/stories/:id`, requireAuth, apiHandler(async (req, res) => { + const storyId = req.params.id; + const existingStory = await drizzleService.getStory(storyId); + + if (!existingStory) { + throw new NotFoundError('Story'); + } + + const libraryId = req.session!.user!.libraryId; + if (req.session!.user!.role === 'library_admin' && existingStory.libraryId !== libraryId) { + throw new AuthorizationError('You can only delete stories for your library'); + } + + const deleted = await drizzleService.deleteStory(storyId); + if (!deleted) { + throw new NotFoundError('Story'); } + return res.status(204).send(); + })); + + // Admin timelines endpoints + app.get(`${global_path}/admin/stories/:id/timelines`, requireAuth, apiHandler(async (req, res) => { const storyId = req.params.id; - // Get the story first to verify ownership const story = await drizzleService.getStory(storyId); if (!story) { - return res.status(404).json({ error: 'Story not found' }); + throw new NotFoundError('Story'); + } + + // Verify ownership for library admins + const libraryId = req.session!.user!.libraryId; + if (req.session!.user!.role === 'library_admin' && story.libraryId !== libraryId) { + throw new AuthorizationError('You can only access timelines for stories in your library'); } - // Skip ownership check for testing // Get the timelines const timelines = await drizzleService.getTimelinesByStoryId(storyId); - console.log("Retrieved timelines:", timelines); return res.status(200).json(timelines); })); - app.post(`${global_path}/admin/stories/:id/timelines`, apiHandler(async (req, res) => { - // Relaxed authentication for testing - if (!req.session.user) { - return res.status(403).json({ error: 'Unauthorized - not logged in' }); - } - + app.post(`${global_path}/admin/stories/:id/timelines`, requireAuth, apiHandler(async (req, res) => { const storyId = req.params.id; - // Get the story first to verify it exists const story = await drizzleService.getStory(storyId); if (!story) { - return res.status(404).json({ error: 'Story not found' }); + throw new NotFoundError('Story'); + } + + // Verify ownership for library admins + const libraryId = req.session!.user!.libraryId; + if (req.session!.user!.role === 'library_admin' && story.libraryId !== libraryId) { + throw new AuthorizationError('You can only add timelines to stories in your library'); } // Create timeline data @@ -132,13 +165,30 @@ export function registerStoriesRoutes(app: Express, global_path: string) { updatedAt: new Date() }; - console.log("Creating timeline with data:", timelineData); const timeline = await drizzleService.createTimeline(timelineData); - console.log("Timeline created successfully:", timeline); - return res.status(200).json(timeline); + return res.status(201).json(timeline); })); - // Stories endpoints + // Stories endpoints - specific routes before parametric to avoid /stories/tags matching :id + app.get(`${global_path}/stories/tags`, async (req, res) => { + try { + const stories = await drizzleService.getStories({ published: true, approved: true }); + const allTags = new Set(); + + stories.forEach(story => { + if (story.tags && Array.isArray(story.tags)) { + story.tags.forEach(tag => allTags.add(tag)); + } + }); + + const sortedTags = Array.from(allTags).sort(); + return res.status(200).json(sortedTags); + } catch (error) { + console.error("Error fetching story tags:", error); + return res.status(500).json({ error: 'Internal server error' }); + } + }); + app.get(`${global_path}/stories`, async (req, res) => { try { // Extract query parameters @@ -185,24 +235,6 @@ export function registerStoriesRoutes(app: Express, global_path: string) { // Get individual story app.get(`${global_path}/stories/:id`, async (req, res, next) => { try { - // Skip the tags endpoint - special case - if (req.params.id === 'tags') { - const allStories = await drizzleService.getStories(); - const uniqueTags = new Set(); - allStories.forEach((story: Story) => { - if (story.tags && Array.isArray(story.tags)) { - story.tags.forEach((tag: string) => { - if (tag) uniqueTags.add(tag); - }); - } - }); - - return res.status(200).json({ - success: true, - data: Array.from(uniqueTags) - }); - } - const storyId = req.params.id; const story = await drizzleService.getStory(storyId); @@ -218,24 +250,4 @@ export function registerStoriesRoutes(app: Express, global_path: string) { next(error); } }); - - // Get all story tags - app.get(`${global_path}/stories/tags`, async (req, res) => { - try { - const stories = await drizzleService.getStories({ published: true, approved: true }); - const allTags = new Set(); - - stories.forEach(story => { - if (story.tags && Array.isArray(story.tags)) { - story.tags.forEach(tag => allTags.add(tag)); - } - }); - - const sortedTags = Array.from(allTags).sort(); - return res.status(200).json(sortedTags); - } catch (error) { - console.error("Error fetching story tags:", error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); } diff --git a/src/routes/superadmin.routes.ts b/src/routes/superadmin.routes.ts index e0d8a94..b18d7cb 100644 --- a/src/routes/superadmin.routes.ts +++ b/src/routes/superadmin.routes.ts @@ -1,9 +1,24 @@ +/** + * Super Admin Routes + * + * Platform stats, pending stories/media, approve/reject, libraries and users + * management. All endpoints require super_admin role (enforced in route or middleware). + * + * @module src/routes/superadmin.routes + */ + import type { Express } from "express"; -import drizzleService from "../../services/drizzle-services"; +import drizzleService from "../services/drizzle-services"; import bcrypt from "bcrypt"; - +import { sendApiError, ErrorCode } from "../utils/api-response"; + +/** + * Registers super admin routes: stats, moderation (stories/media), approve/reject, libraries, users CRUD, password reset. + * All endpoints assume super_admin role is enforced by caller/middleware. + * @param app - Express application + * @param global_path - Base path (e.g. /api/v1) + */ export function registerSuperAdminRoutes(app: Express, global_path: string) { - // Super Admin stats endpoint app.get(`${global_path}/sadmin/stats`, async (req, res) => { try { // Get counts of various entities for the dashboard @@ -195,7 +210,7 @@ export function registerSuperAdminRoutes(app: Express, global_path: string) { } } - return res.status(500).json({ error: error?.message || 'Internal server error' }); + return sendApiError(res, 500, 'An unexpected error occurred. Please try again or contact support.', ErrorCode.INTERNAL_ERROR); } }); diff --git a/src/services/admin.service.ts b/src/services/admin.service.ts new file mode 100644 index 0000000..565548d --- /dev/null +++ b/src/services/admin.service.ts @@ -0,0 +1,216 @@ +/** + * Admin Service + * + * Library-admin dashboard: stats, analytics, activity, galleries, and + * Cloudinary image deletion. All operations are scoped by libraryId where applicable. + * + * @module src/services/admin.service + */ + +import drizzleService from './drizzle-services'; +import { logger } from '../middlewares/logger'; + +export interface DashboardStats { + totalStories: number; + publishedStories: number; + totalMedia: number; + approvedMedia: number; + totalEvents: number; + upcomingEvents: number; + totalMessages: number; + unreadMessages: number; +} + +export interface AnalyticsData { + visitorData: Array<{ + date: string; + visitors: number; + uniqueVisitors: number; + }>; + contentData: Array<{ + name: string; + views: number; + engagement: number; + }>; + engagementData: Array<{ + date: string; + avgTimeSpent: number; + interactionRate: number; + }>; + topPerformers: { + topStory: string; + topStoryViews: number; + topGallery: string; + topGalleryViews: number; + avgTimeOnPage: string; + avgTimeIncrease: number; + }; +} + +export interface ActivityItem { + type: string; + title: string; + timestamp: Date | string | null; + status: string; +} + +export class AdminService { + /** Aggregates dashboard counts (stories, media, events, messages) for a library. */ + async getDashboardStats(libraryId: string): Promise { + const stories = await drizzleService.getStories({ libraryId }); + const mediaItems = await drizzleService.getMediaItems({ libraryId }); + const events = await drizzleService.getEvents({ libraryId }); + const messages = await drizzleService.getContactMessages({ libraryId }); + + return { + totalStories: stories.length, + publishedStories: stories.filter((s) => s.isPublished).length, + totalMedia: mediaItems.length, + approvedMedia: mediaItems.filter((m) => m.isApproved).length, + totalEvents: events.length, + upcomingEvents: events.filter((e) => new Date(e.eventDate) > new Date()).length, + totalMessages: messages.length, + unreadMessages: messages.filter((m) => !m.isRead).length, + }; + } + + /** Builds analytics payload (visitor/content/engagement/top performers) for a library. */ + async getDashboardAnalytics(libraryId: string): Promise { + const analytics = await drizzleService.getAnalytics({ libraryId }); + + // Process analytics data for charts + const last30Days = Array.from({ length: 30 }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() - i); + return date.toISOString().split('T')[0]; + }).reverse(); + + const visitorData = last30Days.map((date) => { + const dayAnalytics = analytics.filter( + (a) => a.date && new Date(a.date).toISOString().split('T')[0] === date + ); + const totalViews = dayAnalytics.reduce((sum, a) => sum + (a.views || 0), 0); + + return { + date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + visitors: totalViews, + uniqueVisitors: Math.floor(totalViews * 0.7), // Approximate unique visitors + }; + }); + + const contentData = [ + { + name: 'Stories', + views: analytics.filter((a) => a.storyId).reduce((sum, a) => sum + (a.views || 0), 0), + engagement: 75, + }, + { + name: 'Gallery', + views: analytics + .filter((a) => a.pageType === 'gallery') + .reduce((sum, a) => sum + (a.views || 0), 0), + engagement: 85, + }, + { + name: 'Library Profile', + views: analytics + .filter((a) => a.pageType === 'library_profile') + .reduce((sum, a) => sum + (a.views || 0), 0), + engagement: 65, + }, + ]; + + const engagementData = last30Days.slice(-7).map((date) => ({ + date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + avgTimeSpent: Math.floor(Math.random() * 300) + 120, // Mock data for demo + interactionRate: Math.floor(Math.random() * 40) + 60, + })); + + const topPerformers = { + topStory: 'Featured Exhibition', + topStoryViews: Math.max(...analytics.filter((a) => a.storyId).map((a) => a.views || 0), 0), + topGallery: 'Main Collection', + topGalleryViews: Math.max( + ...analytics.filter((a) => a.pageType === 'gallery').map((a) => a.views || 0), + 0 + ), + avgTimeOnPage: '4:32', + avgTimeIncrease: 12, + }; + + return { + visitorData, + contentData, + engagementData, + topPerformers, + }; + } + + /** Returns recent activity (stories, messages, events) for a library. */ + async getDashboardActivity(libraryId: string): Promise { + const stories = await drizzleService.getStories({ libraryId, limit: 5 }); + const messages = await drizzleService.getContactMessages({ libraryId, limit: 5 }); + const events = await drizzleService.getEvents({ libraryId, limit: 5 }); + + const recentActivity: ActivityItem[] = [ + ...stories.map((s) => ({ + type: 'story', + title: `Story updated: ${s.title}`, + timestamp: s.updatedAt || s.createdAt, + status: s.isPublished ? 'published' : 'draft', + })), + ...messages.map((m) => ({ + type: 'message', + title: `New inquiry: ${m.subject}`, + timestamp: m.createdAt, + status: m.isRead ? 'read' : 'unread', + })), + ...events.map((e) => ({ + type: 'event', + title: `Event: ${e.title}`, + timestamp: e.createdAt, + status: e.isPublished ? 'published' : 'draft', + })), + ] + .sort( + (a, b) => + new Date(b.timestamp ?? 0).getTime() - new Date(a.timestamp ?? 0).getTime() + ) + .slice(0, 10); + + return recentActivity; + } + + /** Returns list of all gallery names/ids from media. */ + async getGalleries() { + return drizzleService.getGalleries(); + } + + /** Deletes an image from Cloudinary by public ID. Throws if not configured or delete fails. */ + async deleteImage(publicId: string): Promise { + const { cloudinaryService } = await import('../../config/bucket-storage/cloudinary'); + + if (!cloudinaryService.isReady()) { + throw new Error('Cloudinary not configured'); + } + + try { + // Decode the public ID (it may be URL encoded) + const decodedPublicId = decodeURIComponent(publicId); + const success = await cloudinaryService.deleteImage(decodedPublicId); + + if (!success) { + throw new Error('Image not found or already deleted'); + } + + logger.info('Image deleted', { publicId: decodedPublicId }); + return true; + } catch (error) { + throw new Error( + `Failed to delete image: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} + +export const adminService = new AdminService(); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..5addd03 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,73 @@ +/** + * Authentication Service + * + * Validates credentials against the database and returns session user payloads. + * Uses bcrypt for password comparison. Throws AuthenticationError on invalid + * username or password. + * + * @module src/services/auth.service + */ + +import { compare } from 'bcrypt'; +import drizzleService from './drizzle-services'; +import { AuthenticationError } from '../utils/errors'; +import { logger } from '../middlewares/logger'; + +export interface LoginCredentials { + username: string; + password: string; +} + +export interface SessionUser { + id: string; + username: string; + fullName: string; + email: string; + role: string; + libraryId: string; +} + +export class AuthService { + /** + * Verifies username/password and returns the session user object. Logs + * successful authentication. + */ + async authenticateUser(credentials: LoginCredentials): Promise { + const { username, password } = credentials; + + const user = await drizzleService.getUserByUsername(username); + + if (!user) { + throw new AuthenticationError('Invalid username or password'); + } + + const passwordMatch = await compare(password, user.password); + + if (!passwordMatch) { + throw new AuthenticationError('Invalid username or password'); + } + + logger.info('User authenticated', { userId: user.id, username: user.username }); + + return { + id: user.id as string, + username: user.username, + fullName: user.fullName, + email: user.email, + role: user.role, + libraryId: String(user.libraryId), + }; + } + + /** Fetches a user by ID from the database. */ + async getUserById(userId: string) { + return drizzleService.getUser(userId); + } + + /** Fetches a user by username from the database. */ + async getUserByUsername(username: string) { + return drizzleService.getUserByUsername(username); + } +} + +export const authService = new AuthService(); diff --git a/src/services/contact.service.ts b/src/services/contact.service.ts new file mode 100644 index 0000000..c93fa79 --- /dev/null +++ b/src/services/contact.service.ts @@ -0,0 +1,113 @@ +/** + * Contact Service + * + * Contact messages: list (optional library filter), create, update, reply. + * Reply sends email via sendResponseEmail and records the reply. + * + * @module src/services/contact.service + */ + +import drizzleService from './drizzle-services'; +import { sendResponseEmail } from './email-service'; +import { ContactMessage, InsertContactMessage } from '../../config/database/schema'; +import { NotFoundError, AuthorizationError } from '../utils/errors'; +import { logger } from '../middlewares/logger'; + +export class ContactService { + /** Lists contact messages, optionally filtered by libraryId. */ + async getContactMessages(libraryId?: string): Promise { + const options = libraryId ? { libraryId } : {}; + return drizzleService.getContactMessages(options); + } + + async createContactMessage(data: InsertContactMessage): Promise { + const message = await drizzleService.createContactMessage(data); + logger.info('Contact message created', { messageId: message.id }); + return message; + } + + /** Updates a contact message by ID; throws NotFoundError if not found. */ + async updateContactMessage( + messageId: string, + data: Partial + ): Promise { + const updatedMessage = await drizzleService.updateContactMessage(messageId, data); + + if (!updatedMessage) { + throw new NotFoundError('Contact message'); + } + + logger.info('Contact message updated', { messageId }); + return updatedMessage; + } + + /** Gets a contact message by ID; throws NotFoundError if not found. */ + async getContactMessage(messageId: string): Promise { + const message = await drizzleService.getContactMessage(messageId); + + if (!message) { + throw new NotFoundError('Contact message'); + } + + return message; + } + + /** Sends reply email, creates message response record, and marks message responded; throws if message/library not found or email fails. */ + async replyToMessage( + messageId: string, + subject: string, + message: string, + userId: string, + libraryId: string + ) { + if (!subject || !message) { + throw new Error('Subject and message are required'); + } + + // Get the original message + const originalMessage = await drizzleService.getContactMessage(messageId); + if (!originalMessage || originalMessage.libraryId !== libraryId) { + throw new NotFoundError('Message'); + } + + // Get library information + const library = await drizzleService.getLibrary(libraryId); + if (!library) { + throw new NotFoundError('Library'); + } + + // Send email response to visitor + const emailSent = await sendResponseEmail({ + visitorEmail: originalMessage.email, + visitorName: originalMessage.name, + originalSubject: originalMessage.subject, + responseSubject: subject, + responseMessage: message, + libraryName: library.name, + libraryEmail: 'noreply@library.com', + }); + + if (!emailSent) { + throw new Error('Failed to send email response'); + } + + // Create message response record + const response = await drizzleService.createMessageResponse({ + contactMessageId: messageId, + respondedBy: userId, + subject, + message, + }); + + // Update contact message status + await drizzleService.updateContactMessage(messageId, { + responseStatus: 'responded', + isRead: true, + }); + + logger.info('Contact message replied', { messageId }); + return response; + } +} + +export const contactService = new ContactService(); diff --git a/services/drizzle-services.ts b/src/services/drizzle-services.ts similarity index 91% rename from services/drizzle-services.ts rename to src/services/drizzle-services.ts index 5a8455c..fb2a884 100644 --- a/services/drizzle-services.ts +++ b/src/services/drizzle-services.ts @@ -1,3 +1,12 @@ +/** + * Drizzle Services (Database Storage) + * + * Implements IStorage: CRUD for users, libraries, stories, media, timelines, + * events, contact messages, analytics, message responses, email templates; + * health check and gallery list. Uses Drizzle ORM and config/database/db. + * + * @module src/services/drizzle-services + */ import { User, @@ -23,17 +32,20 @@ import { users, libraries, stories, mediaItems, timelines, events, contactMessages, analytics, messageResponses, emailTemplates, -} from "../config/database/schema"; +} from "../../config/database/schema"; -import dbPool from "../config/database/db"; -import { eq, desc, and, gte, lte, isNull } from "drizzle-orm"; -import { IStorage } from "../config/database/storage"; +import dbPool from "../../config/database/db"; +import { eq, desc, and, gte, lte, isNull, sql } from "drizzle-orm"; +import { IStorage } from "../../config/database/storage"; const { db } = dbPool; - +/** + * Database storage implementation: all entity CRUD and queries via Drizzle. + */ export class DatabaseStorage implements IStorage { // Users + /** Fetches a user by ID. */ async getUser(id: string): Promise { try { const [user] = await db.select().from(users).where(eq(users.id, id)); @@ -44,6 +56,7 @@ export class DatabaseStorage implements IStorage { } } + /** Fetches a user by username. */ async getUserByUsername(username: string): Promise { try { const [user] = await db.select().from(users).where(eq(users.username, username)); @@ -54,6 +67,7 @@ export class DatabaseStorage implements IStorage { } } + /** Fetches all users for a library. */ async getUsersByLibraryId(libraryId: string): Promise { try { return db.select().from(users).where(eq(users.libraryId, libraryId)); @@ -84,6 +98,7 @@ export class DatabaseStorage implements IStorage { } } + /** Updates a user by ID. */ async updateUser(id: string, data: Partial): Promise { try { const [updatedUser] = await db.update(users).set(data).where(eq(users.id, id)).returning(); @@ -105,6 +120,7 @@ export class DatabaseStorage implements IStorage { } } + /** Fetches a library by name. */ async getLibraryByName(name: string): Promise { try { const [library] = await db.select().from(libraries).where(eq(libraries.name, name)); @@ -165,6 +181,7 @@ export class DatabaseStorage implements IStorage { } } + /** Returns total library count with optional filters. */ async getTotalLibraries(options?: { approved?: boolean; featured?: boolean; @@ -209,6 +226,7 @@ export class DatabaseStorage implements IStorage { } } + /** Updates a library by ID. */ async updateLibrary(id: string, data: Partial): Promise { try { const [updatedLibrary] = await db.update(libraries).set(data).where(eq(libraries.id, id)).returning(); @@ -220,6 +238,7 @@ export class DatabaseStorage implements IStorage { } // Stories + /** Fetches a story by ID. */ async getStory(id: string): Promise { try { const [story] = await db.select().from(stories).where(eq(stories.id, id)); @@ -230,6 +249,7 @@ export class DatabaseStorage implements IStorage { } } + /** Lists stories with optional filters. */ async getStories(options?: { libraryId?: string; published?: boolean; @@ -286,6 +306,7 @@ export class DatabaseStorage implements IStorage { } } + /** Creates a story. */ async createStory(insertStory: InsertStory): Promise { try { const [story] = await db.insert(stories).values(insertStory).returning(); @@ -306,6 +327,7 @@ export class DatabaseStorage implements IStorage { } } + /** Deletes a story by ID. */ async deleteStory(id: string): Promise { try { const result = await db.delete(stories).where(eq(stories.id, id)).returning(); @@ -317,6 +339,7 @@ export class DatabaseStorage implements IStorage { } // Media Items + /** Fetches a media item by ID. */ async getMediaItem(id: string): Promise { try { const [mediaItem] = await db.select().from(mediaItems).where(eq(mediaItems.id, id)); @@ -382,6 +405,7 @@ export class DatabaseStorage implements IStorage { } } + /** Creates a media item. */ async createMediaItem(insertMediaItem: InsertMediaItem): Promise { try { const [mediaItem] = await db.insert(mediaItems).values(insertMediaItem).returning(); @@ -402,6 +426,7 @@ export class DatabaseStorage implements IStorage { } } + /** Deletes a media item by ID. */ async deleteMediaItem(id: string): Promise { try { const result = await db.delete(mediaItems).where(eq(mediaItems.id, id)).returning(); @@ -423,6 +448,7 @@ export class DatabaseStorage implements IStorage { } } + /** Lists timelines for a story. */ async getTimelinesByStoryId(storyId: string): Promise { try { return db.select().from(timelines).where(eq(timelines.storyId, storyId)); @@ -432,6 +458,7 @@ export class DatabaseStorage implements IStorage { } } + /** Creates a timeline. */ async createTimeline(insertTimeline: InsertTimeline): Promise { try { const [timeline] = await db.insert(timelines).values(insertTimeline).returning(); @@ -452,6 +479,7 @@ export class DatabaseStorage implements IStorage { } } + /** Deletes a timeline by ID. */ async deleteTimeline(id: string): Promise { try { const result = await db.delete(timelines).where(eq(timelines.id, id)).returning(); @@ -473,6 +501,7 @@ export class DatabaseStorage implements IStorage { } } + /** Lists events with optional filters. */ async getEvents(options?: { libraryId?: string; published?: boolean; @@ -526,6 +555,7 @@ export class DatabaseStorage implements IStorage { } } + /** Creates an event. */ async createEvent(insertEvent: InsertEvent): Promise { try { const [event] = await db.insert(events).values(insertEvent).returning(); @@ -536,6 +566,7 @@ export class DatabaseStorage implements IStorage { } } + /** Updates an event by ID. */ async updateEvent(id: string, data: Partial): Promise { try { const [updatedEvent] = await db.update(events).set(data).where(eq(events.id, id)).returning(); @@ -546,6 +577,7 @@ export class DatabaseStorage implements IStorage { } } + /** Deletes an event by ID. */ async deleteEvent(id: string): Promise { try { const result = await db.delete(events).where(eq(events.id, id)).returning(); @@ -557,6 +589,7 @@ export class DatabaseStorage implements IStorage { } // Contact Messages + /** Fetches a contact message by ID. */ async getContactMessage(id: string): Promise { try { const [message] = await db.select().from(contactMessages).where(eq(contactMessages.id, id)); @@ -567,6 +600,7 @@ export class DatabaseStorage implements IStorage { } } + /** Lists contact messages with optional filters. */ async getContactMessages(options?: { libraryId?: string; read?: boolean; @@ -619,6 +653,7 @@ export class DatabaseStorage implements IStorage { } } + /** Updates a contact message by ID. */ async updateContactMessage(id: string, data: Partial): Promise { try { const [updatedMessage] = await db.update(contactMessages).set(data).where(eq(contactMessages.id, id)).returning(); @@ -640,6 +675,7 @@ export class DatabaseStorage implements IStorage { } // Analytics + /** Fetches analytics rows with optional filters. */ async getAnalytics(options: { libraryId?: string; storyId?: string; @@ -724,6 +760,7 @@ export class DatabaseStorage implements IStorage { } // Message Responses + /** Fetches a message response by ID. */ async getMessageResponse(id: string): Promise { try { const [response] = await db.select().from(messageResponses).where(eq(messageResponses.id, id)); @@ -745,6 +782,7 @@ export class DatabaseStorage implements IStorage { } } + /** Creates a message response. */ async createMessageResponse(insertResponse: InsertMessageResponse): Promise { try { const [response] = await db.insert(messageResponses).values(insertResponse).returning(); @@ -766,6 +804,7 @@ export class DatabaseStorage implements IStorage { } // Email Templates + /** Fetches an email template by ID. */ async getEmailTemplate(id: string): Promise { try { const [template] = await db.select().from(emailTemplates).where(eq(emailTemplates.id, id)); @@ -803,6 +842,7 @@ export class DatabaseStorage implements IStorage { } } + /** Creates an email template. */ async createEmailTemplate(insertTemplate: InsertEmailTemplate): Promise { try { const [template] = await db.insert(emailTemplates).values(insertTemplate).returning(); @@ -823,6 +863,7 @@ export class DatabaseStorage implements IStorage { } } + /** Deletes an email template by ID. */ async deleteEmailTemplate(id: string): Promise { try { const result = await db.delete(emailTemplates).where(eq(emailTemplates.id, id)).returning(); @@ -834,6 +875,7 @@ export class DatabaseStorage implements IStorage { } // Galleries (unique galleryId values from mediaItems) + /** Returns distinct gallery IDs from media items. */ async getGalleries(): Promise { try { const rows = await db.select({ galleryId: mediaItems.galleryId }).from(mediaItems); @@ -850,15 +892,24 @@ export class DatabaseStorage implements IStorage { } } - // Health check method + // Health check method (lightweight SELECT 1 with retry for stale connections) + /** Runs a simple DB health check (query). */ async healthCheck(): Promise { - try { - // Perform a simple select to check DB connectivity - await db.select().from(users).limit(1); + const tryOnce = async (): Promise => { + await db.execute(sql`SELECT 1`); return true; + }; + try { + return await tryOnce(); } catch (error) { - console.error('Database health check failed:', error); - return false; + console.error('Database health check failed (first attempt):', error); + try { + await new Promise((r) => setTimeout(r, 300)); + return await tryOnce(); + } catch (retryError) { + console.error('Database health check failed (retry):', retryError); + return false; + } } } diff --git a/services/email-service.ts b/src/services/email-service.ts similarity index 86% rename from services/email-service.ts rename to src/services/email-service.ts index 19573a9..ce0b9e9 100644 --- a/services/email-service.ts +++ b/src/services/email-service.ts @@ -1,3 +1,13 @@ +/** + * Email Service + * + * Sends emails via Gmail SMTP (nodemailer): response emails to contact form + * visitors, acknowledgment emails, and generic sendEmail. Requires + * GMAIL_USER and GMAIL_APP_PASSWORD; logs and returns false if not configured. + * + * @module src/services/email-service + */ + import nodemailer from 'nodemailer'; // Check for Gmail SMTP configuration @@ -17,6 +27,7 @@ const transporter = nodemailer.createTransport({ port: 465, }); +/** Parameters for generic sendEmail. */ interface EmailParams { to: string; from: string; @@ -25,6 +36,7 @@ interface EmailParams { html?: string; } +/** Parameters for sending a reply to a contact form submission. */ interface ResponseEmailParams { visitorEmail: string; visitorName: string; @@ -35,6 +47,11 @@ interface ResponseEmailParams { libraryEmail: string; } +/** + * Sends an email reply to a visitor who submitted a contact form (HTML + text). + * @param params - Reply content and library/visitor details + * @returns true if sent, false if SMTP not configured or send failed + */ export async function sendResponseEmail(params: ResponseEmailParams): Promise { if (!process.env.GMAIL_USER || !process.env.GMAIL_APP_PASSWORD) { console.log("Email would be sent to:", params.visitorEmail); @@ -107,6 +124,11 @@ This is an automated response from our library inquiry system. Please do not rep } } +/** + * Sends an acknowledgment email to a visitor after they submit a contact form. + * @param params - Visitor email/name, subject, library name/email + * @returns true if sent, false if SMTP not configured or send failed + */ export async function sendAcknowledgmentEmail(params: { visitorEmail: string; visitorName: string; @@ -162,6 +184,11 @@ export async function sendAcknowledgmentEmail(params: { } } +/** + * Sends a generic email (to, from, subject, text/html). + * @param params - Email parameters + * @returns true if sent, false if SMTP not configured or send failed + */ export async function sendEmail(params: EmailParams): Promise { if (!process.env.GMAIL_USER || !process.env.GMAIL_APP_PASSWORD) { console.log("Email would be sent:", params); diff --git a/src/services/events.service.ts b/src/services/events.service.ts new file mode 100644 index 0000000..4b94264 --- /dev/null +++ b/src/services/events.service.ts @@ -0,0 +1,129 @@ +/** + * Events Service + * + * CRUD for events. Create/update support optional image upload. List and + * delete are scoped by library or id; role checks for update/delete. + * + * @module src/services/events.service + */ + +import drizzleService from './drizzle-services'; +import { Event, InsertEvent } from '../../config/database/schema'; +import { NotFoundError, AuthorizationError } from '../utils/errors'; +import { logger } from '../middlewares/logger'; +import { uploadImageToCloudinary } from '../routes/shared'; + +export interface CreateEventData extends Partial { + imageUrl?: string | null; +} + +export interface UpdateEventData extends Partial { + imageUrl?: string | null; +} + +export class EventsService { + /** Creates an event for the given library; optional image upload to Cloudinary. */ + async createEvent( + data: CreateEventData, + libraryId: string, + file?: Express.Multer.File + ): Promise { + if (!libraryId) { + throw new Error('Library ID required'); + } + + // Handle event image upload + let imageUrl = data.imageUrl || null; + if (file) { + try { + imageUrl = await uploadImageToCloudinary(file, 'events'); + } catch (error) { + throw new Error('Failed to upload event image'); + } + } + + const eventData: InsertEvent = { + ...data, + libraryId, + imageUrl, + isApproved: false, // New events need approval + createdAt: new Date(), + } as InsertEvent; + + const event = await drizzleService.createEvent(eventData); + logger.info('Event created', { eventId: event.id, libraryId }); + + return event; + } + + async updateEvent( + eventId: string, + data: UpdateEventData, + userLibraryId: string, + userRole: string, + file?: Express.Multer.File + ): Promise { + const existingEvent = await drizzleService.getEvent(eventId); + + if (!existingEvent) { + throw new NotFoundError('Event'); + } + + // Check ownership + if (userRole === 'library_admin' && existingEvent.libraryId !== userLibraryId) { + throw new AuthorizationError('You can only edit events for your library'); + } + + // Handle event image upload + let imageUrl = data.imageUrl ?? existingEvent.imageUrl; + if (file) { + try { + imageUrl = await uploadImageToCloudinary(file, 'events'); + } catch (error) { + throw new Error('Failed to upload event image'); + } + } + + const updateData: Partial = { + ...data, + imageUrl, + }; + + const updatedEvent = await drizzleService.updateEvent(eventId, updateData); + if (!updatedEvent) { + throw new Error('Failed to update event'); + } + logger.info('Event updated', { eventId }); + + return updatedEvent; + } + + /** Lists events, optionally filtered by libraryId. */ + async getEvents(options?: { libraryId?: string }): Promise { + return drizzleService.getEvents(options); + } + + async getEvent(eventId: string): Promise { + const event = await drizzleService.getEvent(eventId); + + if (!event) { + throw new NotFoundError('Event'); + } + + return event; + } + + /** Deletes an event by ID; throws NotFoundError if not found. */ + async deleteEvent(eventId: string): Promise { + const deleted = await drizzleService.deleteEvent(eventId); + + if (!deleted) { + throw new NotFoundError('Event'); + } + + logger.info('Event deleted', { eventId }); + return true; + } +} + +export const eventsService = new EventsService(); diff --git a/src/services/libraries.service.ts b/src/services/libraries.service.ts new file mode 100644 index 0000000..62d5383 --- /dev/null +++ b/src/services/libraries.service.ts @@ -0,0 +1,147 @@ +/** + * Libraries Service + * + * CRUD for libraries. Create/update handle logo and featured image uploads. + * Update/delete enforce libraryId and role (library_admin or super_admin). + * + * @module src/services/libraries.service + */ + +import drizzleService from './drizzle-services'; +import { Library, InsertLibrary } from '../../config/database/schema'; +import { NotFoundError, AuthorizationError } from '../utils/errors'; +import { logger } from '../middlewares/logger'; +import { uploadImageToCloudinary } from '../routes/shared'; + +export interface CreateLibraryData extends Partial { + logoUrl?: string | null; + featuredImageUrl?: string | null; +} + +export interface UpdateLibraryData extends Partial { + logoUrl?: string | null; + featuredImageUrl?: string | null; +} + +export interface LibraryFiles { + logo?: Express.Multer.File[]; + featuredImage?: Express.Multer.File[]; +} + +export class LibrariesService { + /** Creates a library with optional logo/featured image uploads. */ + async createLibrary( + data: CreateLibraryData, + files?: LibraryFiles + ): Promise { + // Handle logo upload + let logoUrl = data.logoUrl || null; + if (files?.logo && files.logo[0]) { + try { + logoUrl = await uploadImageToCloudinary(files.logo[0], 'libraries/logos'); + } catch (error) { + throw new Error('Failed to upload logo'); + } + } + + // Handle featured image upload + let featuredImageUrl = data.featuredImageUrl || null; + if (files?.featuredImage && files.featuredImage[0]) { + try { + featuredImageUrl = await uploadImageToCloudinary( + files.featuredImage[0], + 'libraries/featured' + ); + } catch (error) { + throw new Error('Failed to upload featured image'); + } + } + + const libraryData: InsertLibrary = { + ...data, + logoUrl, + featuredImageUrl, + isApproved: false, // New libraries need approval + createdAt: new Date(), + } as InsertLibrary; + + const library = await drizzleService.createLibrary(libraryData); + logger.info('Library created', { libraryId: library.id }); + + return library; + } + + async updateLibrary( + libraryId: string, + data: UpdateLibraryData, + userLibraryId: string, + userRole: string, + files?: LibraryFiles + ): Promise { + const existingLibrary = await drizzleService.getLibrary(libraryId); + + if (!existingLibrary) { + throw new NotFoundError('Library'); + } + + // Check if library admin is updating their own library + if (userRole === 'library_admin' && userLibraryId !== libraryId) { + throw new AuthorizationError('You can only edit your own library'); + } + + // Handle logo upload + let logoUrl = data.logoUrl ?? existingLibrary.logoUrl; + if (files?.logo && files.logo[0]) { + try { + logoUrl = await uploadImageToCloudinary(files.logo[0], 'libraries/logos'); + } catch (error) { + throw new Error('Failed to upload logo'); + } + } + + // Handle featured image upload + let featuredImageUrl = data.featuredImageUrl ?? existingLibrary.featuredImageUrl; + if (files?.featuredImage && files.featuredImage[0]) { + try { + featuredImageUrl = await uploadImageToCloudinary( + files.featuredImage[0], + 'libraries/featured' + ); + } catch (error) { + throw new Error('Failed to upload featured image'); + } + } + + const updateData: Partial = { + ...data, + logoUrl, + featuredImageUrl, + }; + + const updatedLibrary = await drizzleService.updateLibrary(libraryId, updateData); + if (!updatedLibrary) { + throw new Error('Failed to update library'); + } + logger.info('Library updated', { libraryId }); + + return updatedLibrary; + } + + /** Lists all libraries. */ + async getLibraries(): Promise { + return drizzleService.getLibraries(); + } + + /** Gets a library by ID; throws NotFoundError if not found. */ + async getLibrary(libraryId: string): Promise { + const library = await drizzleService.getLibrary(libraryId); + + if (!library) { + throw new NotFoundError('Library'); + } + + return library; + } +} + +export const librariesService = new LibrariesService(); diff --git a/src/services/maintenance.service.ts b/src/services/maintenance.service.ts new file mode 100644 index 0000000..fa2a6d5 --- /dev/null +++ b/src/services/maintenance.service.ts @@ -0,0 +1,234 @@ +/** + * Maintenance Service + * + * Health check, maintenance mode toggle, scheduled windows, and backup list/create. + * State is in-memory; in production should be persisted (e.g. DB/Redis). + * + * @module src/services/maintenance.service + */ + +import drizzleService from './drizzle-services'; + +/** In-memory maintenance state; prefer DB/Redis in production. */ +let maintenanceMode = false; +const maintenanceWindows: any[] = []; +const backupHistory: any[] = [ + { id: 1, type: 'full', size: '2.3 GB', created: new Date('2025-06-18T02:00:00Z'), status: 'completed' }, + { id: 2, type: 'database', size: '890 MB', created: new Date('2025-06-17T02:00:00Z'), status: 'completed' }, + { id: 3, type: 'files', size: '1.4 GB', created: new Date('2025-06-16T02:00:00Z'), status: 'completed' }, + { id: 4, type: 'database', size: '885 MB', created: new Date('2025-06-15T02:00:00Z'), status: 'completed' }, +]; + +export interface SystemHealth { + service: string; + status: string; + uptime: string; + responseTime: number; + lastCheck: Date; +} + +export interface SystemMetrics { + cpuUsage: number; + memoryUsage: number; + diskUsage: number; + networkTraffic: string; +} + +export interface MaintenanceStatus { + maintenanceMode: boolean; + systemHealth: SystemHealth[]; + systemMetrics: SystemMetrics; + maintenanceWindows: any[]; + backupHistory: any[]; +} + +export interface MaintenanceWindow { + id: number; + title: string; + description?: string; + scheduledStart: Date; + scheduledEnd: Date | null; + affectedServices: string[]; + status: string; + createdAt: Date; +} + +export class MaintenanceService { + /** Runs DB health check. */ + async healthCheck(): Promise { + return drizzleService.healthCheck(); + } + + /** Returns current maintenance mode, system health, metrics, windows, and backup history. */ + async getMaintenanceStatus(): Promise { + const systemHealth: SystemHealth[] = [ + { + service: 'Web Server', + status: 'healthy', + uptime: '15 days, 3 hours', + responseTime: 145, + lastCheck: new Date(), + }, + { + service: 'Database', + status: 'healthy', + uptime: '15 days, 3 hours', + responseTime: 23, + lastCheck: new Date(), + }, + { + service: 'File Storage', + status: 'warning', + uptime: '2 days, 1 hour', + responseTime: 287, + lastCheck: new Date(), + }, + { + service: 'Email Service', + status: 'healthy', + uptime: '15 days, 3 hours', + responseTime: 412, + lastCheck: new Date(), + }, + { + service: 'CDN', + status: 'healthy', + uptime: '30 days, 12 hours', + responseTime: 89, + lastCheck: new Date(), + }, + ]; + + const systemMetrics: SystemMetrics = { + cpuUsage: Math.floor(Math.random() * 30) + 15, + memoryUsage: Math.floor(Math.random() * 40) + 50, + diskUsage: Math.floor(Math.random() * 30) + 30, + networkTraffic: '1.2 GB/day', + }; + + return { + maintenanceMode, + systemHealth, + systemMetrics, + maintenanceWindows, + backupHistory, + }; + } + + /** Sets maintenance mode on/off; returns new value. */ + async toggleMaintenanceMode(enabled: boolean): Promise { + maintenanceMode = enabled; + return maintenanceMode; + } + + /** Schedules a maintenance window; requires title and scheduledStart. */ + async scheduleMaintenance(data: { + title: string; + description?: string; + scheduledStart: string; + scheduledEnd?: string; + affectedServices?: string[]; + }): Promise { + if (!data.title || !data.scheduledStart) { + throw new Error('Title and start time are required'); + } + + const newWindow: MaintenanceWindow = { + id: Date.now(), + title: data.title, + description: data.description, + scheduledStart: new Date(data.scheduledStart), + scheduledEnd: data.scheduledEnd ? new Date(data.scheduledEnd) : null, + affectedServices: data.affectedServices || [], + status: 'scheduled', + createdAt: new Date(), + }; + + maintenanceWindows.push(newWindow); + return newWindow; + } + + async createBackup(type: 'database' | 'files' | 'full'): Promise { + if (!['database', 'files', 'full'].includes(type)) { + throw new Error('Invalid backup type'); + } + + // Simulate backup creation + const sizes = { + database: `${Math.floor(Math.random() * 500) + 800} MB`, + files: `${Math.floor(Math.random() * 800) + 1200} MB`, + full: `${Math.floor(Math.random() * 1000) + 2000} MB`, + }; + + const newBackup = { + id: Date.now(), + type, + size: sizes[type], + created: new Date(), + status: 'running', + }; + + backupHistory.unshift(newBackup); + + // Simulate backup completion after 3 seconds + setTimeout(() => { + const backup = backupHistory.find((b) => b.id === newBackup.id); + if (backup) { + backup.status = 'completed'; + } + }, 3000); + + return newBackup; + } + + /** Returns list of backup records. */ + async getBackups(): Promise { + return backupHistory; + } + + /** Refreshes and returns current system health metrics. */ + async refreshSystemStatus(): Promise<{ systemHealth: SystemHealth[] }> { + // Simulate system check with random variations + const systemHealth: SystemHealth[] = [ + { + service: 'Web Server', + status: 'healthy', + uptime: '15 days, 3 hours', + responseTime: Math.floor(Math.random() * 50) + 120, + lastCheck: new Date(), + }, + { + service: 'Database', + status: 'healthy', + uptime: '15 days, 3 hours', + responseTime: Math.floor(Math.random() * 20) + 15, + lastCheck: new Date(), + }, + { + service: 'File Storage', + status: Math.random() > 0.8 ? 'warning' : 'healthy', + uptime: '2 days, 1 hour', + responseTime: Math.floor(Math.random() * 100) + 200, + lastCheck: new Date(), + }, + { + service: 'Email Service', + status: 'healthy', + uptime: '15 days, 3 hours', + responseTime: Math.floor(Math.random() * 200) + 350, + lastCheck: new Date(), + }, + { + service: 'CDN', + status: 'healthy', + uptime: '30 days, 12 hours', + responseTime: Math.floor(Math.random() * 30) + 70, + lastCheck: new Date(), + }, + ]; + + return { systemHealth }; + } +} + +export const maintenanceService = new MaintenanceService(); diff --git a/src/services/media.service.ts b/src/services/media.service.ts new file mode 100644 index 0000000..af7edaa --- /dev/null +++ b/src/services/media.service.ts @@ -0,0 +1,148 @@ +/** + * Media Service + * + * List/get/create/update/delete media items. Filters: libraryId, galleryId, + * mediaType, tags, approved, limit, offset. Create/update support file upload and role checks. + * + * @module src/services/media.service + */ + +import drizzleService from './drizzle-services'; +import { MediaItem, InsertMediaItem } from '../../config/database/schema'; +import { NotFoundError, AuthorizationError } from '../utils/errors'; +import { logger } from '../middlewares/logger'; +import { uploadImageToCloudinary } from '../routes/shared'; + +export interface MediaFilters { + libraryId?: string; + galleryId?: string; + mediaType?: string; + tags?: string[]; + limit?: number; + offset?: number; + approved?: boolean; +} + +export interface CreateMediaData extends Partial> { + url?: string; +} + +export interface UpdateMediaData extends Partial> { + url?: string; +} + +export class MediaService { + /** Lists media items with optional filters (libraryId, galleryId, mediaType, tags, approved, limit, offset). */ + async getMediaItems(filters?: MediaFilters): Promise { + return drizzleService.getMediaItems(filters); + } + + /** Gets a media item by ID; throws NotFoundError if not found. */ + async getMediaItem(mediaId: string): Promise { + const mediaItem = await drizzleService.getMediaItem(mediaId); + + if (!mediaItem) { + throw new NotFoundError('Media item'); + } + + return mediaItem; + } + + /** Creates a media item for the library; optional file upload. Requires libraryId and url or file. */ + /** Creates a media item for the library; optional file upload. Requires libraryId and url or file. */ + async createMediaItem( + data: CreateMediaData, + libraryId: string, + file?: Express.Multer.File + ): Promise { + if (!libraryId) { + throw new Error('Library ID required'); + } + + // Handle media file upload + let url = data.url || null; + if (file) { + try { + url = await uploadImageToCloudinary(file, 'media'); + } catch (error) { + throw new Error('Failed to upload media file'); + } + } + + if (!url) { + throw new Error('Media URL or file is required'); + } + + const mediaData: InsertMediaItem = { + ...data, + libraryId, + url: url as string, + isApproved: false, // New media needs approval + createdAt: new Date(), + } as InsertMediaItem; + + const mediaItem = await drizzleService.createMediaItem(mediaData); + logger.info('Media item created', { mediaId: mediaItem.id, libraryId }); + + return mediaItem; + } + + /** Updates a media item by ID; enforces library ownership for library_admin. Optional file upload. */ + /** Updates a media item by ID; enforces library ownership for library_admin. Optional file upload. */ + async updateMediaItem( + mediaId: string, + data: UpdateMediaData, + userLibraryId: string, + userRole: string, + file?: Express.Multer.File + ): Promise { + const existingMedia = await drizzleService.getMediaItem(mediaId); + + if (!existingMedia) { + throw new NotFoundError('Media item'); + } + + // Check ownership + if (userRole === 'library_admin' && existingMedia.libraryId !== userLibraryId) { + throw new AuthorizationError('You can only edit media for your library'); + } + + // Handle media file upload + let url: string | null = data.url ?? existingMedia.url; + if (file) { + try { + url = await uploadImageToCloudinary(file, 'media'); + } catch (error) { + throw new Error('Failed to upload media file'); + } + } + + const updateData: Partial = { + ...data, + url: url ?? existingMedia.url, + }; + + const updatedMedia = await drizzleService.updateMediaItem(mediaId, updateData); + if (!updatedMedia) { + throw new Error('Failed to update media item'); + } + logger.info('Media item updated', { mediaId }); + + return updatedMedia; + } + + /** Returns sorted list of all unique media tags. */ + /** Returns sorted list of all unique media tags. */ + async getMediaTags(): Promise { + const mediaItems = await drizzleService.getMediaItems(); + const allTags = new Set(); + mediaItems.forEach((item) => { + if (item.tags && Array.isArray(item.tags)) { + item.tags.forEach((tag) => allTags.add(tag)); + } + }); + return Array.from(allTags).sort(); + } +} + +export const mediaService = new MediaService(); diff --git a/src/services/settings.service.ts b/src/services/settings.service.ts new file mode 100644 index 0000000..537f1bc --- /dev/null +++ b/src/services/settings.service.ts @@ -0,0 +1,89 @@ +/** + * Settings Service + * + * Get/update platform settings and test email. State is in-memory; in + * production should be persisted (e.g. database). + * + * @module src/services/settings.service + */ + +/** In-memory platform settings; prefer database in production. */ +let platformSettings = { + general: { + siteName: 'Library Digital Platform', + siteDescription: 'A comprehensive platform for library digital experiences', + contactEmail: 'contact@library-platform.com', + supportEmail: 'support@library-platform.com', + defaultLanguage: 'en', + timezone: 'UTC', + allowRegistration: true, + requireEmailVerification: true, + maintenanceMode: false, + }, + security: { + passwordMinLength: 8, + requireStrongPasswords: true, + sessionTimeout: 24, + maxLoginAttempts: 5, + enableTwoFactor: false, + allowPasswordReset: true, + }, + email: { + smtpHost: '', + smtpPort: 587, + smtpUser: '', + smtpPassword: '', + fromEmail: 'noreply@library-platform.com', + fromName: 'Library Platform', + enableEmailNotifications: true, + }, + content: { + maxFileSize: 10, + allowedFileTypes: ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'mp4', 'mp3'], + autoModeration: true, + requireApproval: true, + enableComments: true, + enableRatings: true, + }, + appearance: { + primaryColor: '#2563eb', + secondaryColor: '#64748b', + logo: '', + favicon: '', + customCSS: '', + darkModeEnabled: true, + }, + notifications: { + newUserSignup: true, + newLibraryApplication: true, + contentFlagged: true, + systemAlerts: true, + weeklyReports: true, + emailDigest: false, + }, +}; + +export type PlatformSettings = typeof platformSettings; + +export class SettingsService { + /** Returns current platform settings (in-memory). */ + async getSettings(): Promise { + return platformSettings; + } + + /** Merges updates into platform settings and returns the result. */ + async updateSettings(updates: Partial): Promise { + // Merge updates with existing settings + platformSettings = { ...platformSettings, ...updates }; + return platformSettings; + } + + /** Simulates sending a test email; returns success message. */ + /** Simulates sending a test email; returns success message. */ + async testEmail(): Promise<{ message: string }> { + // Simulate email test + return { message: 'Test email sent successfully' }; + } +} + +export const settingsService = new SettingsService(); diff --git a/src/services/stories.service.ts b/src/services/stories.service.ts new file mode 100644 index 0000000..b2b0a68 --- /dev/null +++ b/src/services/stories.service.ts @@ -0,0 +1,169 @@ +/** + * Stories Service + * + * Business logic for stories and timelines: create, update, get, list (with + * filters), tags, timelines by story. Delegates to drizzleService and + * uploadImageToCloudinary for persistence and media. + * + * @module src/services/stories.service + */ + +import drizzleService from './drizzle-services'; +import { Story, InsertStory } from '../../config/database/schema'; +import { NotFoundError, AuthorizationError } from '../utils/errors'; +import { logger } from '../middlewares/logger'; +import { uploadImageToCloudinary } from '../routes/shared'; + +export interface StoryFilters { + libraryId?: string; + published?: boolean; + approved?: boolean; + featured?: boolean; + tags?: string[]; + limit?: number; + offset?: number; +} + +export interface CreateStoryData extends Partial { + featuredImageUrl?: string | null; + isPublished?: boolean; +} + +export interface UpdateStoryData extends Partial { + featuredImageUrl?: string | null; +} + +export class StoriesService { + /** Creates a story for the library; optional featured image upload. */ + async createStory( + data: CreateStoryData, + libraryId: string, + file?: Express.Multer.File + ): Promise { + // Handle featured image upload + let featuredImageUrl = data.featuredImageUrl || null; + if (file) { + featuredImageUrl = await uploadImageToCloudinary(file, 'stories'); + } + + const storyData: InsertStory = { + ...data, + libraryId, + featuredImageUrl, + isApproved: false, // New stories need approval + isPublished: data.isPublished || false, + isFeatured: false, // Only super admin can feature stories + createdAt: new Date(), + } as InsertStory; + + const story = await drizzleService.createStory(storyData); + logger.info('Story created', { storyId: story.id, libraryId }); + + return story; + } + + /** Updates a story by ID; enforces library ownership for library_admin. Preserves approval status. */ + async updateStory( + storyId: string, + data: UpdateStoryData, + userLibraryId: string, + userRole: string, + file?: Express.Multer.File + ): Promise { + const existingStory = await drizzleService.getStory(storyId); + + if (!existingStory) { + throw new NotFoundError('Story'); + } + + // Check ownership for library admins + if (userRole === 'library_admin' && existingStory.libraryId !== userLibraryId) { + throw new AuthorizationError('You can only edit stories for your library'); + } + + // Handle featured image upload + let featuredImageUrl = data.featuredImageUrl ?? existingStory.featuredImageUrl; + if (file) { + featuredImageUrl = await uploadImageToCloudinary(file, 'stories'); + } + + // Preserve approval status - only super admin can change this + const updateData: Partial = { + ...data, + featuredImageUrl, + isApproved: existingStory.isApproved, // Preserve approval status + updatedAt: new Date(), + }; + + const updatedStory = await drizzleService.updateStory(storyId, updateData); + if (!updatedStory) { + throw new Error('Failed to update story'); + } + logger.info('Story updated', { storyId, libraryId: userLibraryId }); + + return updatedStory; + } + + /** Gets a story by ID; throws NotFoundError if not found. */ + async getStory(storyId: string): Promise { + const story = await drizzleService.getStory(storyId); + + if (!story) { + throw new NotFoundError('Story'); + } + + return story; + } + + /** Lists stories with optional filters (libraryId, published, approved, featured, tags, limit, offset). */ + async getStories(filters?: StoryFilters): Promise { + return drizzleService.getStories(filters); + } + + /** Returns sorted list of unique tags from published+approved stories. */ + async getStoryTags(): Promise { + const stories = await drizzleService.getStories({ published: true, approved: true }); + const allTags = new Set(); + + stories.forEach((story) => { + if (story.tags && Array.isArray(story.tags)) { + story.tags.forEach((tag) => allTags.add(tag)); + } + }); + + return Array.from(allTags).sort(); + } + + /** Returns timelines for a story; throws NotFoundError if story not found. */ + async getTimelinesByStoryId(storyId: string) { + // Verify story exists + const story = await drizzleService.getStory(storyId); + if (!story) { + throw new NotFoundError('Story'); + } + + return drizzleService.getTimelinesByStoryId(storyId); + } + + /** Creates a timeline for a story; throws NotFoundError if story not found. */ + /** Creates a timeline for a story; throws NotFoundError if story not found. */ + async createTimeline(storyId: string, timelineData: any) { + // Verify story exists + const story = await drizzleService.getStory(storyId); + if (!story) { + throw new NotFoundError('Story'); + } + + const timeline = await drizzleService.createTimeline({ + ...timelineData, + storyId, + createdAt: new Date(), + updatedAt: new Date(), + }); + + logger.info('Timeline created', { timelineId: timeline.id, storyId }); + return timeline; + } +} + +export const storiesService = new StoriesService(); diff --git a/src/services/superadmin.service.ts b/src/services/superadmin.service.ts new file mode 100644 index 0000000..3fe07a6 --- /dev/null +++ b/src/services/superadmin.service.ts @@ -0,0 +1,223 @@ +/** + * Super Admin Service + * + * Platform-wide stats, pending stories/media, approve/reject, library and user + * management. All operations assume super_admin authorization is enforced by route/middleware. + * + * @module src/services/superadmin.service + */ + +import drizzleService from './drizzle-services'; +import bcrypt from 'bcrypt'; +import { User, InsertUser } from '../../config/database/schema'; +import { NotFoundError } from '../utils/errors'; +import { logger } from '../middlewares/logger'; + +export interface SuperAdminStats { + totalLibraries: number; + pendingLibraries: number; + totalStories: number; + pendingStories: number; + totalMedia: number; + uniqueGalleries: number; + totalUsers: number; + activeUsers: number; + recentActivity: Array<{ + type: string; + user: string; + title?: string; + library?: string; + count?: number; + timestamp: Date; + }>; +} + +export class SuperAdminService { + /** Returns platform-wide stats (libraries, stories, media, users, recent activity). */ + async getStats(): Promise { + // Get counts of various entities for the dashboard + const libraries = await drizzleService.getLibraries(); + const stories = await drizzleService.getStories(); + const mediaItems = await drizzleService.getMediaItems(); + const usersPromises = libraries.map((library) => + drizzleService.getUsersByLibraryId(library.id) + ); + const usersArrays = await Promise.all(usersPromises); + const users = usersArrays.flat(); + + return { + totalLibraries: libraries.length, + pendingLibraries: libraries.filter((m) => !m.isApproved).length, + totalStories: stories.length, + pendingStories: stories.filter((s) => !s.isApproved).length, + totalMedia: mediaItems.length, + uniqueGalleries: Array.from(new Set(mediaItems.map((m) => m.galleryId))).length, + totalUsers: users.length, + activeUsers: users.filter((u) => u.lastLoginAt !== null).length, + recentActivity: [ + { + type: 'user_signup', + user: 'National Gallery Admin', + timestamp: new Date(Date.now() - 1000 * 60 * 5), + }, + { + type: 'story_published', + user: 'MoMA Admin', + title: 'Summer Exhibition Preview', + timestamp: new Date(Date.now() - 1000 * 60 * 60), + }, + { + type: 'media_uploaded', + user: 'Louvre Admin', + count: 15, + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 3), + }, + { + type: 'library_approved', + user: 'Super Admin', + library: 'Contemporary Arts Center', + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24), + }, + ], + }; + } + + /** Returns stories that are not approved. */ + /** Returns stories that are not approved. */ + async getPendingStories() { + return drizzleService.getStories({ approved: false }); + } + + /** Returns media items that are not approved. */ + /** Returns media items that are not approved. */ + async getPendingMedia() { + return drizzleService.getMediaItems({ approved: false }); + } + + /** Sets story as approved; throws NotFoundError if not found. */ + async approveStory(storyId: string) { + const updatedStory = await drizzleService.updateStory(storyId, { + isApproved: true, + }); + + if (!updatedStory) { + throw new NotFoundError('Story'); + } + + logger.info('Story approved', { storyId }); + return updatedStory; + } + + /** Sets story as not approved; throws NotFoundError if not found. */ + /** Sets story as not approved; throws NotFoundError if not found. */ + async rejectStory(storyId: string) { + const updatedStory = await drizzleService.updateStory(storyId, { isApproved: false }); + + if (!updatedStory) { + throw new NotFoundError('Story'); + } + + logger.info('Story rejected', { storyId }); + return updatedStory; + } + + /** Sets media item as approved; throws NotFoundError if not found. */ + async approveMedia(mediaId: string) { + const updatedMedia = await drizzleService.updateMediaItem(mediaId, { isApproved: true }); + + if (!updatedMedia) { + throw new NotFoundError('Media item'); + } + + logger.info('Media approved', { mediaId }); + return updatedMedia; + } + + /** Sets media item as not approved; throws NotFoundError if not found. */ + /** Sets media item as not approved; throws NotFoundError if not found. */ + async rejectMedia(mediaId: string) { + const updatedMedia = await drizzleService.updateMediaItem(mediaId, { isApproved: false }); + + if (!updatedMedia) { + throw new NotFoundError('Media item'); + } + + logger.info('Media rejected', { mediaId }); + return updatedMedia; + } + + /** Returns all libraries. */ + async getLibraries() { + return drizzleService.getLibraries(); + } + + async getUsers(): Promise { + // Get all users across all libraries + const libraries = await drizzleService.getLibraries(); + const usersPromises = libraries.map((library) => + drizzleService.getUsersByLibraryId(library.id) + ); + const usersArrays = await Promise.all(usersPromises); + return usersArrays.flat(); + } + + /** Creates a user; hashes password. Requires username, password, email, fullName, role. */ + async createUser(userData: Partial & { password: string }): Promise { + // Validate required fields + if ( + !userData.username || + !userData.password || + !userData.email || + !userData.fullName || + !userData.role + ) { + throw new Error('Missing required fields'); + } + + const hashedPassword = await bcrypt.hash(userData.password, 10); + + const newUser = await drizzleService.createUser({ + username: userData.username, + password: hashedPassword, + email: userData.email, + fullName: userData.fullName, + role: userData.role, + libraryId: userData.libraryId || null, + isActive: userData.isActive !== undefined ? userData.isActive : true, + }); + + logger.info('User created', { userId: newUser.id, username: newUser.username }); + return newUser; + } + + async updateUser(userId: string, updateData: Partial): Promise { + const updatedUser = await drizzleService.updateUser(userId, updateData); + + if (!updatedUser) { + throw new NotFoundError('User'); + } + + logger.info('User updated', { userId }); + return updatedUser; + } + + /** Resets user password (hashed); throws if user not found or password empty. */ + async resetUserPassword(userId: string, password: string): Promise { + if (!password) { + throw new Error('Password is required'); + } + + // Hash new password + const hashedPassword = await bcrypt.hash(password, 10); + + const updatedUser = await drizzleService.updateUser(userId, { password: hashedPassword }); + + if (!updatedUser) { + throw new NotFoundError('User'); + } + + logger.info('User password reset', { userId }); + } +} + +export const superAdminService = new SuperAdminService(); diff --git a/src/utils/api-response.ts b/src/utils/api-response.ts new file mode 100644 index 0000000..9e28753 --- /dev/null +++ b/src/utils/api-response.ts @@ -0,0 +1,146 @@ +/** + * Centralized API response formatting (DRY). + * All error and success responses should use these helpers so clients get a + * consistent shape and no sensitive or system-internal information is leaked. + * + * @module src/utils/api-response + */ + +/** Error codes returned to clients (machine-readable). Do not expose internal system names. */ +export const ErrorCode = { + VALIDATION_ERROR: 'VALIDATION_ERROR', + AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR', + AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + RATE_LIMITED: 'RATE_LIMITED', + INTERNAL_ERROR: 'INTERNAL_ERROR', +} as const; + +export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; + +/** Standard error response body for all API errors. */ +export interface ApiErrorResponse { + success: false; + error: string; + code?: ErrorCodeType; + errors?: Record; + timestamp: string; +} + +/** Standard success response body (optional; many endpoints return data directly). */ +export interface ApiSuccessResponse { + success: true; + data?: T; + message?: string; + timestamp: string; +} + +/** Patterns that may leak internal systems (DB, runtime, IPs). Never sent to client in production. */ +const SENSITIVE_PATTERNS = [ + /\b(?:postgres|postgresql|mysql|mongodb|redis|drizzle|knex|prisma|sequelize)\b/i, + /\b(?:ECONNREFUSED|ETIMEDOUT|ENOTFOUND|ECONNRESET)\b/, + /\b(?:node\.js|express|connect\.sid)\b/i, + /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?/, // IPv4 with optional port + /\[::?\d*\](?::\d+)?/, // IPv6 + /localhost(?::\d+)?/i, + /\/var\/|\/usr\/|\/home\//, +]; + +/** + * Sanitizes a message so it is safe to return to the client. + * In production, replaces or strips anything that could reveal stack traces, + * internal systems, or network details. + */ +export function sanitizeErrorMessage( + message: string, + isProduction: boolean +): string { + if (!message || typeof message !== 'string') { + return 'An unexpected error occurred. Please try again later.'; + } + if (!isProduction) { + return message; + } + const trimmed = message.trim(); + for (const pattern of SENSITIVE_PATTERNS) { + if (pattern.test(trimmed)) { + return 'An unexpected error occurred. Please try again or contact support.'; + } + } + // Allow short, generic messages; otherwise replace with generic text + if (trimmed.length > 200) { + return 'An unexpected error occurred. Please try again or contact support.'; + } + return trimmed; +} + +/** + * Builds a single error response object. Use this everywhere (error handler, + * rate limiters, and any route that sends an error JSON). + */ +export function formatErrorResponse(options: { + statusCode: number; + error: string; + code?: ErrorCodeType; + errors?: Record; + isProduction: boolean; + /** Raw message only used in development for debugging; sanitized in production. */ + rawMessage?: string; +}): { statusCode: number; body: ApiErrorResponse } { + const { + statusCode, + error, + code, + errors, + isProduction, + rawMessage, + } = options; + const safeError = isProduction + ? sanitizeErrorMessage(error, true) + : (rawMessage ?? error); + const body: ApiErrorResponse = { + success: false, + error: safeError, + timestamp: new Date().toISOString(), + }; + if (code) body.code = code; + if (errors && Object.keys(errors).length > 0) body.errors = errors; + return { statusCode, body }; +} + +/** + * Builds a standard success response (optional use for consistency). + */ +export function formatSuccessResponse(payload: { + data?: T; + message?: string; +}): ApiSuccessResponse { + return { + success: true, + ...payload, + timestamp: new Date().toISOString(), + }; +} + +const isProductionEnv = () => process.env.NODE_ENV === 'production'; + +/** + * Sends a standardized error response. Use in routes instead of ad-hoc res.status().json({ error: '...' }). + */ +export function sendApiError( + res: { status: (code: number) => { json: (body: unknown) => void } }, + statusCode: number, + error: string, + code?: ErrorCodeType, + errors?: Record +): void { + const { body } = formatErrorResponse({ + statusCode, + error, + code, + errors, + isProduction: isProductionEnv(), + }); + res.status(statusCode).json(body); +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index f69c19c..cca1300 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -12,7 +12,7 @@ * * Error Handler: * All errors are caught by the error handler middleware in - * middlewares/errors/error-handler.ts which formats the response. + * src/middlewares/error-handler.ts which formats the response. */ /** diff --git a/src/utils/validations.ts b/src/utils/validations.ts new file mode 100644 index 0000000..3526fc4 --- /dev/null +++ b/src/utils/validations.ts @@ -0,0 +1,28 @@ +/** + * Shared Validation Utilities + * + * Express middleware for common validations (e.g. UUID path params). Use in + * routes that expect :id in params to return 400 for invalid UUID format. + * + * @module src/utils/validations + */ + +import { Request, Response, NextFunction } from 'express'; +import { sendApiError, ErrorCode } from './api-response'; + +interface UUIDRequest extends Request { + params: { + id: string; + [key: string]: string; + }; +} + +/** Validates req.params.id is a valid UUID v4; sends 400 with standard error format otherwise. */ +export const validateUUID = (req: UUIDRequest, res: Response, next: NextFunction): void => { + const { id } = req.params; + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) { + sendApiError(res, 400, 'Invalid UUID format', ErrorCode.VALIDATION_ERROR); + return; + } + next(); +}; diff --git a/src/validations/story.schemas.ts b/src/validations/story.schemas.ts index 9fa8dd1..cf80eed 100644 --- a/src/validations/story.schemas.ts +++ b/src/validations/story.schemas.ts @@ -17,22 +17,24 @@ import { z } from 'zod'; import { insertStorySchema } from '../../config/database/schema'; -// Base schema for creating stories - extends DB schema with API constraints -export const createStorySchema = insertStorySchema +// Base schema for creating stories (object only - for partial we need this before refine) +const createStorySchemaBase = insertStorySchema .omit({ summary: true }) // summary not required in API .extend({ title: z.string().min(1, 'Title is required').max(200), content: z.string().min(1, 'Content is required'), featuredImageUrl: z.string().url().optional().nullable(), tags: z.array(z.string()).optional().default([]), - }) - .refine((data) => !data.libraryId || z.string().uuid().safeParse(data.libraryId).success, { - message: 'Library ID must be a valid UUID', - path: ['libraryId'], }); -// Update schema allows partial updates (all fields optional) -export const updateStorySchema = createStorySchema.partial(); +// Create schema with refinement (ZodEffects - no .partial()) +export const createStorySchema = createStorySchemaBase.refine( + (data) => !data.libraryId || z.string().uuid().safeParse(data.libraryId).success, + { message: 'Library ID must be a valid UUID', path: ['libraryId'] } +); + +// Update schema allows partial updates (from base object schema) +export const updateStorySchema = createStorySchemaBase.partial(); // Query parameter schema for filtering stories export const storyQuerySchema = z.object({ diff --git a/tests/README.md b/tests/README.md index 83deb48..5d2f682 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,18 +1,20 @@ # Testing Documentation -This directory contains unit tests for the museumCall backend project. +This directory contains unit tests for the Library Management REST API project. ## Test Structure ``` tests/ β”œβ”€β”€ setup.ts # Jest setup file -β”œβ”€β”€ utils/ -β”‚ └── mocks.ts # Common mocks and utilities +β”œβ”€β”€ helpers/ +β”‚ └── mocks.ts # Shared test helpers (mock Request/Response/session) β”œβ”€β”€ unit/ β”‚ β”œβ”€β”€ controllers/ # Controller unit tests β”‚ β”œβ”€β”€ middlewares/ # Middleware unit tests -β”‚ └── utils/ # Utility unit tests +β”‚ β”œβ”€β”€ routes/ # Route registration tests +β”‚ β”œβ”€β”€ services/ # Service unit tests +β”‚ └── utils/ # Unit tests for src/utils/ (e.g. errors) └── README.md # This file ``` @@ -76,7 +78,7 @@ describe('ComponentName', () => { ``` ### Using Mocks -Common mocks are available in `tests/utils/mocks.ts`: +Common mocks are in `tests/helpers/mocks.ts`: - `createMockRequest()` - Creates a mock Express Request - `createMockResponse()` - Creates a mock Express Response - `createMockNext()` - Creates a mock NextFunction @@ -84,7 +86,7 @@ Common mocks are available in `tests/utils/mocks.ts`: ### Example Test ```typescript -import { createMockRequest, createMockResponse, createMockNext } from '../utils/mocks'; +import { createMockRequest, createMockResponse, createMockNext } from '../helpers/mocks'; describe('MyMiddleware', () => { it('should handle request correctly', () => { diff --git a/tests/utils/mocks.ts b/tests/helpers/mocks.ts similarity index 84% rename from tests/utils/mocks.ts rename to tests/helpers/mocks.ts index 47ba89e..261e1b5 100644 --- a/tests/utils/mocks.ts +++ b/tests/helpers/mocks.ts @@ -1,7 +1,8 @@ /** * Test Utilities and Mocks - * - * Common mocks and utilities for testing + * + * Shared helpers for tests (mock Request/Response/session). For unit tests + * of application code under src/utils/, see tests/unit/utils/. */ import { Request, Response, NextFunction } from 'express'; @@ -43,14 +44,14 @@ export const createMockNext = (): NextFunction => { /** * Creates a mock session with user data */ -export const createMockSession = (user?: { +export const createMockSession = (user?: Partial<{ id: string; username: string; fullName: string; email: string; role: string; libraryId?: string; -}): any => { +}>): any => { const defaultUser = { id: 'test-user-id', username: 'testuser', @@ -61,7 +62,7 @@ export const createMockSession = (user?: { }; return { - user: user || defaultUser, + user: { ...defaultUser, ...user }, destroy: jest.fn((callback: (err?: Error) => void) => callback(undefined)), }; }; diff --git a/tests/setup.ts b/tests/setup.ts index eb107ec..694542a 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,11 +1,14 @@ /** * Jest Setup File - * + * * This file runs before all tests and sets up the testing environment. */ -// Mock environment variables if needed +// Must run before any module imports env (e.g. logger, config) process.env.NODE_ENV = 'test'; +process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'test-session-secret-at-least-32-chars-long'; +process.env.GMAIL_USER = process.env.GMAIL_USER || 'test@example.com'; +process.env.GMAIL_APP_PASSWORD = process.env.GMAIL_APP_PASSWORD || 'test-app-password'; // Suppress console logs during tests (optional - uncomment if needed) // global.console = { diff --git a/tests/unit/controllers/admin.controller.test.ts b/tests/unit/controllers/admin.controller.test.ts new file mode 100644 index 0000000..69e71fe --- /dev/null +++ b/tests/unit/controllers/admin.controller.test.ts @@ -0,0 +1,134 @@ +/** + * Unit Tests for Admin Controller + */ + +import { Request, Response } from 'express'; +import { AdminController } from '../../../src/controllers/admin.controller'; +import { adminService } from '../../../src/services/admin.service'; +import { createMockRequest, createMockResponse, createMockSession } from '../../helpers/mocks'; + +jest.mock('../../../src/services/admin.service'); + +const mockedAdminService = adminService as jest.Mocked; + +describe('AdminController', () => { + let adminController: AdminController; + let mockRequest: Partial; + let mockResponse: Partial; + + const sessionUser = { + id: 'user-1', + username: 'admin', + fullName: 'Admin User', + email: 'admin@test.com', + role: 'library_admin', + libraryId: 'lib-1', + }; + + beforeEach(() => { + adminController = new AdminController(); + mockRequest = createMockRequest(); + mockResponse = createMockResponse(); + mockRequest.session = createMockSession(sessionUser) as any; + jest.clearAllMocks(); + }); + + describe('getDashboardStats', () => { + it('should return dashboard stats for library', async () => { + const stats = { + totalStories: 10, + publishedStories: 5, + totalMedia: 20, + approvedMedia: 15, + totalEvents: 3, + upcomingEvents: 2, + totalMessages: 7, + unreadMessages: 2, + }; + mockedAdminService.getDashboardStats.mockResolvedValue(stats); + + await adminController.getDashboardStats(mockRequest as Request, mockResponse as Response); + + expect(mockedAdminService.getDashboardStats).toHaveBeenCalledWith('lib-1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(stats); + }); + + it('should throw when libraryId is missing', async () => { + (mockRequest.session as any).user = { ...sessionUser, libraryId: undefined }; + + await expect( + adminController.getDashboardStats(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow('Library ID required'); + expect(mockedAdminService.getDashboardStats).not.toHaveBeenCalled(); + }); + }); + + describe('getDashboardAnalytics', () => { + it('should return analytics for library', async () => { + const analytics = { + visitorData: [], + contentData: [], + engagementData: [], + topPerformers: {} as any, + }; + mockedAdminService.getDashboardAnalytics.mockResolvedValue(analytics); + + await adminController.getDashboardAnalytics(mockRequest as Request, mockResponse as Response); + + expect(mockedAdminService.getDashboardAnalytics).toHaveBeenCalledWith('lib-1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(analytics); + }); + + it('should throw when libraryId is missing', async () => { + (mockRequest.session as any).user = { ...sessionUser, libraryId: undefined }; + + await expect( + adminController.getDashboardAnalytics(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow('Library ID required'); + }); + }); + + describe('getDashboardActivity', () => { + it('should return activity for library', async () => { + const activity = [{ type: 'story', title: 'Story updated', timestamp: new Date(), status: 'published' }]; + mockedAdminService.getDashboardActivity.mockResolvedValue(activity); + + await adminController.getDashboardActivity(mockRequest as Request, mockResponse as Response); + + expect(mockedAdminService.getDashboardActivity).toHaveBeenCalledWith('lib-1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(activity); + }); + }); + + describe('getGalleries', () => { + it('should return galleries', async () => { + const galleries = [{ id: 'g1', name: 'Gallery 1' }]; + mockedAdminService.getGalleries.mockResolvedValue(galleries as any); + + await adminController.getGalleries(mockRequest as Request, mockResponse as Response); + + expect(mockedAdminService.getGalleries).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(galleries); + }); + }); + + describe('deleteImage', () => { + it('should delete image by publicId', async () => { + mockRequest.params = { publicId: 'folder/image123' }; + mockedAdminService.deleteImage.mockResolvedValue(undefined as any); + + await adminController.deleteImage(mockRequest as Request, mockResponse as Response); + + expect(mockedAdminService.deleteImage).toHaveBeenCalledWith('folder/image123'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Image deleted successfully', + }); + }); + }); +}); diff --git a/tests/unit/controllers/auth.controller.test.ts b/tests/unit/controllers/auth.controller.test.ts index 0ee90fe..d71e43f 100644 --- a/tests/unit/controllers/auth.controller.test.ts +++ b/tests/unit/controllers/auth.controller.test.ts @@ -3,16 +3,14 @@ */ import { Request, Response } from 'express'; -import { compare } from 'bcrypt'; import { AuthController } from '../../../src/controllers/auth.controller'; import { AuthenticationError } from '../../../src/utils/errors'; -import drizzleService from '../../../services/drizzle-services'; +import { authService } from '../../../src/services/auth.service'; import { logger } from '../../../src/middlewares/logger'; -import { createMockRequest, createMockResponse, createMockSession } from '../../utils/mocks'; +import { createMockRequest, createMockResponse, createMockSession } from '../../helpers/mocks'; // Mock dependencies -jest.mock('bcrypt'); -jest.mock('../../../services/drizzle-services'); +jest.mock('../../../src/services/auth.service'); jest.mock('../../../src/middlewares/logger', () => ({ logger: { info: jest.fn(), @@ -21,8 +19,7 @@ jest.mock('../../../src/middlewares/logger', () => ({ }, })); -const mockedCompare = compare as jest.MockedFunction; -const mockedDrizzleService = drizzleService as jest.Mocked; +const mockedAuthService = authService as jest.Mocked; describe('AuthController', () => { let authController: AuthController; @@ -37,16 +34,13 @@ describe('AuthController', () => { }); describe('login', () => { - const mockUser = { + const mockSessionUser = { id: 'user-123', username: 'testuser', - password: 'hashedPassword', fullName: 'Test User', email: 'test@example.com', role: 'library_admin', libraryId: 'library-123', - createdAt: new Date(), - updatedAt: new Date(), }; it('should successfully login with valid credentials', async () => { @@ -56,32 +50,19 @@ describe('AuthController', () => { }; mockRequest.session = createMockSession() as any; - mockedDrizzleService.getUserByUsername.mockResolvedValue(mockUser as any); - (mockedCompare as jest.Mock).mockResolvedValue(true); + mockedAuthService.authenticateUser.mockResolvedValue(mockSessionUser); await authController.login(mockRequest as Request, mockResponse as Response); - expect(mockedDrizzleService.getUserByUsername).toHaveBeenCalledWith('testuser'); - expect(mockedCompare).toHaveBeenCalledWith('password123', 'hashedPassword'); - expect((mockRequest.session as any)!.user).toEqual({ - id: 'user-123', + expect(mockedAuthService.authenticateUser).toHaveBeenCalledWith({ username: 'testuser', - fullName: 'Test User', - email: 'test@example.com', - role: 'library_admin', - libraryId: 'library-123', + password: 'password123', }); + expect((mockRequest.session as any)!.user).toEqual(mockSessionUser); expect(mockResponse.status).toHaveBeenCalledWith(200); expect(mockResponse.json).toHaveBeenCalledWith({ success: true, - data: { - id: 'user-123', - username: 'testuser', - fullName: 'Test User', - email: 'test@example.com', - role: 'library_admin', - libraryId: 'library-123', - }, + data: mockSessionUser, }); expect(logger.info).toHaveBeenCalledWith('User logged in', { userId: 'user-123', @@ -95,14 +76,18 @@ describe('AuthController', () => { password: 'password123', }; - mockedDrizzleService.getUserByUsername.mockResolvedValue(undefined); + mockedAuthService.authenticateUser.mockRejectedValue( + new AuthenticationError('Invalid username or password') + ); await expect( authController.login(mockRequest as Request, mockResponse as Response) - ).rejects.toThrow(AuthenticationError); + ).rejects.toMatchObject({ message: 'Invalid username or password', statusCode: 401 }); - expect(mockedDrizzleService.getUserByUsername).toHaveBeenCalledWith('nonexistent'); - expect(mockedCompare).not.toHaveBeenCalled(); + expect(mockedAuthService.authenticateUser).toHaveBeenCalledWith({ + username: 'nonexistent', + password: 'password123', + }); expect(mockResponse.json).not.toHaveBeenCalled(); }); @@ -112,25 +97,28 @@ describe('AuthController', () => { password: 'wrongpassword', }; - mockedDrizzleService.getUserByUsername.mockResolvedValue(mockUser as any); - (mockedCompare as jest.Mock).mockResolvedValue(false); + mockedAuthService.authenticateUser.mockRejectedValue( + new AuthenticationError('Invalid username or password') + ); await expect( authController.login(mockRequest as Request, mockResponse as Response) - ).rejects.toThrow(AuthenticationError); + ).rejects.toMatchObject({ message: 'Invalid username or password', statusCode: 401 }); - expect(mockedDrizzleService.getUserByUsername).toHaveBeenCalledWith('testuser'); - expect(mockedCompare).toHaveBeenCalledWith('wrongpassword', 'hashedPassword'); - expect((mockRequest.session as any)!.user).toBeUndefined(); + expect(mockedAuthService.authenticateUser).toHaveBeenCalledWith({ + username: 'testuser', + password: 'wrongpassword', + }); + expect((mockRequest.session as { user?: unknown })?.user).toBeUndefined(); }); - it('should handle errors from getUserByUsername', async () => { + it('should handle errors from authenticateUser', async () => { mockRequest.body = { username: 'testuser', password: 'password123', }; - mockedDrizzleService.getUserByUsername.mockRejectedValue(new Error('Database error')); + mockedAuthService.authenticateUser.mockRejectedValue(new Error('Database error')); await expect( authController.login(mockRequest as Request, mockResponse as Response) diff --git a/tests/unit/controllers/contact.controller.test.ts b/tests/unit/controllers/contact.controller.test.ts new file mode 100644 index 0000000..12c2fc0 --- /dev/null +++ b/tests/unit/controllers/contact.controller.test.ts @@ -0,0 +1,116 @@ +/** + * Unit Tests for Contact Controller + */ + +import { Request, Response } from 'express'; +import { ContactController } from '../../../src/controllers/contact.controller'; +import { contactService } from '../../../src/services/contact.service'; +import { AuthorizationError } from '../../../src/utils/errors'; +import { createMockRequest, createMockResponse, createMockSession } from '../../helpers/mocks'; + +jest.mock('../../../src/services/contact.service'); + +const mockedContactService = contactService as jest.Mocked; + +describe('ContactController', () => { + let contactController: ContactController; + let mockRequest: Partial; + let mockResponse: Partial; + + const sessionUser = { + id: 'user-1', + username: 'admin', + fullName: 'Admin User', + email: 'admin@test.com', + role: 'library_admin', + libraryId: 'lib-1', + }; + + beforeEach(() => { + contactController = new ContactController(); + mockRequest = createMockRequest(); + mockResponse = createMockResponse(); + mockRequest.session = createMockSession(sessionUser) as any; + jest.clearAllMocks(); + }); + + describe('getContactMessages', () => { + it('should return contact messages for library', async () => { + const messages = [{ id: 'm1', subject: 'Hello', libraryId: 'lib-1' }] as any[]; + mockedContactService.getContactMessages.mockResolvedValue(messages); + + await contactController.getContactMessages(mockRequest as Request, mockResponse as Response); + + expect(mockedContactService.getContactMessages).toHaveBeenCalledWith('lib-1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(messages); + }); + }); + + describe('createContactMessage', () => { + it('should create contact message', async () => { + const body = { name: 'John', email: 'j@test.com', subject: 'Hi', message: 'Hello' }; + mockRequest.body = body; + const created = { id: 'm1', ...body } as any; + mockedContactService.createContactMessage.mockResolvedValue(created); + + await contactController.createContactMessage(mockRequest as Request, mockResponse as Response); + + expect(mockedContactService.createContactMessage).toHaveBeenCalledWith(body); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith(created); + }); + }); + + describe('updateContactMessage', () => { + it('should update contact message', async () => { + mockRequest.params = { id: 'm1' }; + mockRequest.body = { isRead: true }; + const updated = { id: 'm1', isRead: true } as any; + mockedContactService.updateContactMessage.mockResolvedValue(updated); + + await contactController.updateContactMessage(mockRequest as Request, mockResponse as Response); + + expect(mockedContactService.updateContactMessage).toHaveBeenCalledWith('m1', { isRead: true }); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(updated); + }); + }); + + describe('replyToMessage', () => { + it('should reply when user is library_admin', async () => { + mockRequest.params = { id: 'm1' }; + mockRequest.body = { subject: 'Re: Hi', message: 'Reply text' }; + const response = { id: 'r1', subject: 'Re: Hi', message: 'Reply text' } as any; + mockedContactService.replyToMessage.mockResolvedValue(response); + + await contactController.replyToMessage(mockRequest as Request, mockResponse as Response); + + expect(mockedContactService.replyToMessage).toHaveBeenCalledWith( + 'm1', + 'Re: Hi', + 'Reply text', + 'user-1', + 'lib-1' + ); + expect(mockResponse.json).toHaveBeenCalledWith(response); + }); + + it('should throw AuthorizationError when not library_admin', async () => { + (mockRequest.session as any).user = { ...sessionUser, role: 'viewer' }; + + await expect( + contactController.replyToMessage(mockRequest as Request, mockResponse as Response) + ).rejects.toMatchObject({ statusCode: 403, name: 'AuthorizationError' }); + expect(mockedContactService.replyToMessage).not.toHaveBeenCalled(); + }); + + it('should throw AuthorizationError when no user in session', async () => { + mockRequest.session = {} as any; + + await expect( + contactController.replyToMessage(mockRequest as Request, mockResponse as Response) + ).rejects.toMatchObject({ statusCode: 403, name: 'AuthorizationError' }); + }); + }); +}); diff --git a/tests/unit/controllers/events.controller.test.ts b/tests/unit/controllers/events.controller.test.ts new file mode 100644 index 0000000..631f916 --- /dev/null +++ b/tests/unit/controllers/events.controller.test.ts @@ -0,0 +1,131 @@ +/** + * Unit Tests for Events Controller + */ + +import { Request, Response } from 'express'; +import { EventsController } from '../../../src/controllers/events.controller'; +import { eventsService } from '../../../src/services/events.service'; +import { AuthorizationError } from '../../../src/utils/errors'; +import { createMockRequest, createMockResponse, createMockSession } from '../../helpers/mocks'; + +jest.mock('../../../src/services/events.service'); + +const mockedEventsService = eventsService as jest.Mocked; + +describe('EventsController', () => { + let eventsController: EventsController; + let mockRequest: Partial; + let mockResponse: Partial; + + const sessionUser = { + id: 'user-1', + username: 'admin', + fullName: 'Admin User', + email: 'admin@test.com', + role: 'library_admin', + libraryId: 'lib-1', + }; + + beforeEach(() => { + eventsController = new EventsController(); + mockRequest = createMockRequest(); + mockResponse = createMockResponse(); + mockRequest.session = createMockSession(sessionUser) as any; + jest.clearAllMocks(); + }); + + describe('createEvent', () => { + it('should create event when authenticated with libraryId', async () => { + mockRequest.body = { title: 'Event 1', eventDate: '2025-12-01' }; + const event = { id: 'e1', title: 'Event 1', libraryId: 'lib-1' } as any; + mockedEventsService.createEvent.mockResolvedValue(event); + + await eventsController.createEvent(mockRequest as Request, mockResponse as Response); + + expect(mockedEventsService.createEvent).toHaveBeenCalledWith( + mockRequest.body, + 'lib-1', + mockRequest.file + ); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith(event); + }); + + it('should throw when not logged in', async () => { + mockRequest.session = {} as any; + mockRequest.body = { title: 'Event 1' }; + + await expect( + eventsController.createEvent(mockRequest as Request, mockResponse as Response) + ).rejects.toMatchObject({ statusCode: 403, name: 'AuthorizationError' }); + expect(mockedEventsService.createEvent).not.toHaveBeenCalled(); + }); + + it('should throw when libraryId is missing', async () => { + (mockRequest.session as any).user = { ...sessionUser, libraryId: undefined }; + mockRequest.body = { title: 'Event 1' }; + + await expect( + eventsController.createEvent(mockRequest as Request, mockResponse as Response) + ).rejects.toMatchObject({ statusCode: 403, name: 'AuthorizationError' }); + }); + }); + + describe('updateEvent', () => { + it('should update event', async () => { + mockRequest.params = { id: 'e1' }; + mockRequest.body = { title: 'Updated' }; + const updated = { id: 'e1', title: 'Updated', libraryId: 'lib-1' } as any; + mockedEventsService.updateEvent.mockResolvedValue(updated); + + await eventsController.updateEvent(mockRequest as Request, mockResponse as Response); + + expect(mockedEventsService.updateEvent).toHaveBeenCalledWith( + 'e1', + mockRequest.body, + 'lib-1', + 'library_admin', + mockRequest.file + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(updated); + }); + }); + + describe('getEvents', () => { + it('should return events for library when session has libraryId', async () => { + const events = [{ id: 'e1', title: 'Event 1', libraryId: 'lib-1' }] as any[]; + mockedEventsService.getEvents.mockResolvedValue(events); + + await eventsController.getEvents(mockRequest as Request, mockResponse as Response); + + expect(mockedEventsService.getEvents).toHaveBeenCalledWith({ libraryId: 'lib-1' }); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(events); + }); + + it('should return all events when no libraryId in session', async () => { + mockRequest.session = {} as any; + const events = [] as any[]; + mockedEventsService.getEvents.mockResolvedValue(events); + + await eventsController.getEvents(mockRequest as Request, mockResponse as Response); + + expect(mockedEventsService.getEvents).toHaveBeenCalledWith({}); + expect(mockResponse.status).toHaveBeenCalledWith(200); + }); + }); + + describe('deleteEvent', () => { + it('should delete event', async () => { + mockRequest.params = { id: 'e1' }; + mockedEventsService.deleteEvent.mockResolvedValue(true); + + await eventsController.deleteEvent(mockRequest as Request, mockResponse as Response); + + expect(mockedEventsService.deleteEvent).toHaveBeenCalledWith('e1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ success: true }); + }); + }); +}); diff --git a/tests/unit/controllers/libraries.controller.test.ts b/tests/unit/controllers/libraries.controller.test.ts new file mode 100644 index 0000000..d8271c2 --- /dev/null +++ b/tests/unit/controllers/libraries.controller.test.ts @@ -0,0 +1,122 @@ +/** + * Unit Tests for Libraries Controller + */ + +import { Request, Response } from 'express'; +import { LibrariesController } from '../../../src/controllers/libraries.controller'; +import { librariesService } from '../../../src/services/libraries.service'; +import { AuthorizationError } from '../../../src/utils/errors'; +import { createMockRequest, createMockResponse, createMockSession } from '../../helpers/mocks'; + +jest.mock('../../../src/services/libraries.service'); + +const mockedLibrariesService = librariesService as jest.Mocked; + +describe('LibrariesController', () => { + let librariesController: LibrariesController; + let mockRequest: Partial; + let mockResponse: Partial; + + const sessionUser = { + id: 'user-1', + username: 'admin', + fullName: 'Admin User', + email: 'admin@test.com', + role: 'library_admin', + libraryId: 'lib-1', + }; + + beforeEach(() => { + librariesController = new LibrariesController(); + mockRequest = createMockRequest(); + mockResponse = createMockResponse(); + mockRequest.session = createMockSession(sessionUser) as any; + jest.clearAllMocks(); + }); + + describe('createLibrary', () => { + it('should create library with body and files', async () => { + mockRequest.body = { name: 'Library A', description: 'Desc' }; + mockRequest.files = {} as any; + const library = { id: 'lib-1', name: 'Library A' } as any; + mockedLibrariesService.createLibrary.mockResolvedValue(library); + + await librariesController.createLibrary(mockRequest as Request, mockResponse as Response); + + expect(mockedLibrariesService.createLibrary).toHaveBeenCalledWith( + mockRequest.body, + mockRequest.files + ); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith({ success: true, data: library }); + }); + }); + + describe('updateLibrary', () => { + it('should update library when user has libraryId and matches', async () => { + mockRequest.params = { id: 'lib-1' }; + mockRequest.body = { name: 'Updated Name' }; + mockRequest.files = {} as any; + const updated = { id: 'lib-1', name: 'Updated Name' } as any; + mockedLibrariesService.updateLibrary.mockResolvedValue(updated); + + await librariesController.updateLibrary(mockRequest as Request, mockResponse as Response); + + expect(mockedLibrariesService.updateLibrary).toHaveBeenCalledWith( + 'lib-1', + mockRequest.body, + 'lib-1', + 'library_admin', + mockRequest.files + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ success: true, data: updated }); + }); + + it('should throw when not logged in', async () => { + mockRequest.session = {} as any; + mockRequest.params = { id: 'lib-1' }; + mockRequest.body = {}; + + await expect( + librariesController.updateLibrary(mockRequest as Request, mockResponse as Response) + ).rejects.toMatchObject({ statusCode: 403, name: 'AuthorizationError' }); + }); + + it('should throw when libraryId is missing in session', async () => { + (mockRequest.session as any).user = { ...sessionUser, libraryId: undefined }; + mockRequest.params = { id: 'lib-1' }; + + await expect( + librariesController.updateLibrary(mockRequest as Request, mockResponse as Response) + ).rejects.toMatchObject({ statusCode: 403, name: 'AuthorizationError' }); + }); + }); + + describe('getLibraries', () => { + it('should return all libraries', async () => { + const libraries = [{ id: 'lib-1', name: 'Lib 1' }] as any[]; + mockedLibrariesService.getLibraries.mockResolvedValue(libraries); + + await librariesController.getLibraries(mockRequest as Request, mockResponse as Response); + + expect(mockedLibrariesService.getLibraries).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(libraries); + }); + }); + + describe('getLibrary', () => { + it('should return library by id', async () => { + mockRequest.params = { id: 'lib-1' }; + const library = { id: 'lib-1', name: 'Library 1' } as any; + mockedLibrariesService.getLibrary.mockResolvedValue(library); + + await librariesController.getLibrary(mockRequest as Request, mockResponse as Response); + + expect(mockedLibrariesService.getLibrary).toHaveBeenCalledWith('lib-1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(library); + }); + }); +}); diff --git a/tests/unit/controllers/maintenance.controller.test.ts b/tests/unit/controllers/maintenance.controller.test.ts new file mode 100644 index 0000000..309bdc5 --- /dev/null +++ b/tests/unit/controllers/maintenance.controller.test.ts @@ -0,0 +1,188 @@ +/** + * Unit Tests for Maintenance Controller + */ + +import { Request, Response } from 'express'; +import { MaintenanceController } from '../../../src/controllers/maintenance.controller'; +import { maintenanceService } from '../../../src/services/maintenance.service'; +import { createMockRequest, createMockResponse } from '../../helpers/mocks'; + +jest.mock('../../../src/services/maintenance.service'); + +const mockedMaintenanceService = maintenanceService as jest.Mocked; + +describe('MaintenanceController', () => { + let maintenanceController: MaintenanceController; + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + maintenanceController = new MaintenanceController(); + mockRequest = createMockRequest(); + mockResponse = createMockResponse(); + jest.clearAllMocks(); + }); + + describe('healthCheck', () => { + it('should return healthy status when service returns true', async () => { + mockedMaintenanceService.healthCheck.mockResolvedValue(true); + + await maintenanceController.healthCheck(mockRequest as Request, mockResponse as Response); + + expect(mockedMaintenanceService.healthCheck).toHaveBeenCalled(); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'system healthy', + timestamp: expect.any(String), + }) + ); + }); + + it('should return unhealthy status when service returns false', async () => { + mockedMaintenanceService.healthCheck.mockResolvedValue(false); + + await maintenanceController.healthCheck(mockRequest as Request, mockResponse as Response); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'system unhealthy', + timestamp: expect.any(String), + }) + ); + }); + + it('should return 500 when service throws', async () => { + mockedMaintenanceService.healthCheck.mockRejectedValue(new Error('DB down')); + + await maintenanceController.healthCheck(mockRequest as Request, mockResponse as Response); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'system unhealthy', + error: 'Health check failed', + timestamp: expect.any(String), + }) + ); + }); + }); + + describe('getMaintenanceStatus', () => { + it('should return maintenance status', async () => { + const status = { + maintenanceMode: false, + systemHealth: [], + systemMetrics: {} as any, + maintenanceWindows: [], + backupHistory: [], + }; + mockedMaintenanceService.getMaintenanceStatus.mockResolvedValue(status); + + await maintenanceController.getMaintenanceStatus( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockedMaintenanceService.getMaintenanceStatus).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(status); + }); + }); + + describe('toggleMaintenanceMode', () => { + it('should toggle maintenance mode to enabled', async () => { + mockRequest.body = { enabled: true }; + mockedMaintenanceService.toggleMaintenanceMode.mockResolvedValue(true); + + await maintenanceController.toggleMaintenanceMode( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockedMaintenanceService.toggleMaintenanceMode).toHaveBeenCalledWith(true); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + maintenanceMode: true, + message: 'Maintenance mode enabled', + }); + }); + + it('should toggle maintenance mode to disabled', async () => { + mockRequest.body = { enabled: false }; + mockedMaintenanceService.toggleMaintenanceMode.mockResolvedValue(false); + + await maintenanceController.toggleMaintenanceMode( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Maintenance mode disabled' }) + ); + }); + }); + + describe('scheduleMaintenance', () => { + it('should schedule maintenance window', async () => { + mockRequest.body = { + title: 'Upgrade', + scheduledStart: '2025-12-01T00:00:00Z', + }; + const window = { id: 1, title: 'Upgrade', status: 'scheduled' } as any; + mockedMaintenanceService.scheduleMaintenance.mockResolvedValue(window); + + await maintenanceController.scheduleMaintenance( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockedMaintenanceService.scheduleMaintenance).toHaveBeenCalledWith(mockRequest.body); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith(window); + }); + }); + + describe('createBackup', () => { + it('should create backup by type', async () => { + mockRequest.body = { type: 'database' }; + const backup = { id: 1, type: 'database', status: 'running' } as any; + mockedMaintenanceService.createBackup.mockResolvedValue(backup); + + await maintenanceController.createBackup(mockRequest as Request, mockResponse as Response); + + expect(mockedMaintenanceService.createBackup).toHaveBeenCalledWith('database'); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith(backup); + }); + }); + + describe('getBackups', () => { + it('should return backup list', async () => { + const backups = [{ id: 1, type: 'full', status: 'completed' }]; + mockedMaintenanceService.getBackups.mockResolvedValue(backups as any); + + await maintenanceController.getBackups(mockRequest as Request, mockResponse as Response); + + expect(mockedMaintenanceService.getBackups).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(backups); + }); + }); + + describe('refreshSystemStatus', () => { + it('should return refreshed system status', async () => { + const status = { systemHealth: [] }; + mockedMaintenanceService.refreshSystemStatus.mockResolvedValue(status as any); + + await maintenanceController.refreshSystemStatus( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockedMaintenanceService.refreshSystemStatus).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(status); + }); + }); +}); diff --git a/tests/unit/controllers/media.controller.test.ts b/tests/unit/controllers/media.controller.test.ts new file mode 100644 index 0000000..91a1419 --- /dev/null +++ b/tests/unit/controllers/media.controller.test.ts @@ -0,0 +1,157 @@ +/** + * Unit Tests for Media Controller + */ + +import { Request, Response } from 'express'; +import { MediaController } from '../../../src/controllers/media.controller'; +import { mediaService } from '../../../src/services/media.service'; +import { AuthorizationError } from '../../../src/utils/errors'; +import { createMockRequest, createMockResponse, createMockSession } from '../../helpers/mocks'; + +jest.mock('../../../src/services/media.service'); + +const mockedMediaService = mediaService as jest.Mocked; + +describe('MediaController', () => { + let mediaController: MediaController; + let mockRequest: Partial; + let mockResponse: Partial; + + const sessionUser = { + id: 'user-1', + username: 'admin', + fullName: 'Admin User', + email: 'admin@test.com', + role: 'library_admin', + libraryId: 'lib-1', + }; + + beforeEach(() => { + mediaController = new MediaController(); + mockRequest = createMockRequest(); + mockResponse = createMockResponse(); + mockRequest.session = createMockSession(sessionUser) as any; + jest.clearAllMocks(); + }); + + describe('getMediaItems', () => { + it('should return media with query filters', async () => { + mockRequest.query = { libraryId: 'lib-1', approved: 'true', limit: '10' }; + const media = [{ id: 'm1', libraryId: 'lib-1' }] as any[]; + mockedMediaService.getMediaItems.mockResolvedValue(media); + + await mediaController.getMediaItems(mockRequest as Request, mockResponse as Response); + + expect(mockedMediaService.getMediaItems).toHaveBeenCalledWith( + expect.objectContaining({ + libraryId: 'lib-1', + approved: true, + limit: 10, + }) + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(media); + }); + + it('should handle tag as array', async () => { + mockRequest.query = { tag: ['art', 'photo'] }; + mockedMediaService.getMediaItems.mockResolvedValue([]); + + await mediaController.getMediaItems(mockRequest as Request, mockResponse as Response); + + expect(mockedMediaService.getMediaItems).toHaveBeenCalledWith( + expect.objectContaining({ tags: ['art', 'photo'] }) + ); + }); + }); + + describe('getMediaItem', () => { + it('should return single media item', async () => { + mockRequest.params = { id: 'm1' }; + const item = { id: 'm1', url: 'https://example.com/img.jpg' } as any; + mockedMediaService.getMediaItem.mockResolvedValue(item); + + await mediaController.getMediaItem(mockRequest as Request, mockResponse as Response); + + expect(mockedMediaService.getMediaItem).toHaveBeenCalledWith('m1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(item); + }); + }); + + describe('createMediaItem', () => { + it('should create media when authenticated', async () => { + mockRequest.body = { title: 'Photo' }; + const created = { id: 'm1', title: 'Photo', libraryId: 'lib-1' } as any; + mockedMediaService.createMediaItem.mockResolvedValue(created); + + await mediaController.createMediaItem(mockRequest as Request, mockResponse as Response); + + expect(mockedMediaService.createMediaItem).toHaveBeenCalledWith( + mockRequest.body, + 'lib-1', + mockRequest.file + ); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith(created); + }); + + it('should throw when not logged in', async () => { + mockRequest.session = {} as any; + + await expect( + mediaController.createMediaItem(mockRequest as Request, mockResponse as Response) + ).rejects.toMatchObject({ statusCode: 403, name: 'AuthorizationError' }); + }); + + it('should throw when libraryId missing', async () => { + (mockRequest.session as any).user = { ...sessionUser, libraryId: undefined }; + + await expect( + mediaController.createMediaItem(mockRequest as Request, mockResponse as Response) + ).rejects.toMatchObject({ statusCode: 403, name: 'AuthorizationError' }); + }); + }); + + describe('updateMediaItem', () => { + it('should update media item', async () => { + mockRequest.params = { id: 'm1' }; + mockRequest.body = { title: 'Updated' }; + const updated = { id: 'm1', title: 'Updated' } as any; + mockedMediaService.updateMediaItem.mockResolvedValue(updated); + + await mediaController.updateMediaItem(mockRequest as Request, mockResponse as Response); + + expect(mockedMediaService.updateMediaItem).toHaveBeenCalledWith( + 'm1', + mockRequest.body, + 'lib-1', + 'library_admin', + mockRequest.file + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(updated); + }); + }); + + describe('getMediaTags', () => { + it('should return tags when authenticated', async () => { + const tags = ['art', 'photo']; + mockedMediaService.getMediaTags.mockResolvedValue(tags); + + await mediaController.getMediaTags(mockRequest as Request, mockResponse as Response); + + expect(mockedMediaService.getMediaTags).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(tags); + }); + + it('should throw when not logged in', async () => { + mockRequest.session = {} as any; + + await expect( + mediaController.getMediaTags(mockRequest as Request, mockResponse as Response) + ).rejects.toMatchObject({ statusCode: 403, name: 'AuthorizationError' }); + }); + }); +}); diff --git a/tests/unit/controllers/settings.controller.test.ts b/tests/unit/controllers/settings.controller.test.ts new file mode 100644 index 0000000..d8bc1c2 --- /dev/null +++ b/tests/unit/controllers/settings.controller.test.ts @@ -0,0 +1,65 @@ +/** + * Unit Tests for Settings Controller + */ + +import { Request, Response } from 'express'; +import { SettingsController } from '../../../src/controllers/settings.controller'; +import { settingsService } from '../../../src/services/settings.service'; +import { createMockRequest, createMockResponse } from '../../helpers/mocks'; + +jest.mock('../../../src/services/settings.service'); + +const mockedSettingsService = settingsService as jest.Mocked; + +describe('SettingsController', () => { + let settingsController: SettingsController; + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + settingsController = new SettingsController(); + mockRequest = createMockRequest(); + mockResponse = createMockResponse(); + jest.clearAllMocks(); + }); + + describe('getSettings', () => { + it('should return platform settings', async () => { + const settings = { general: { siteName: 'Test' }, security: {}, email: {}, content: {}, appearance: {}, notifications: {} } as any; + mockedSettingsService.getSettings.mockResolvedValue(settings); + + await settingsController.getSettings(mockRequest as Request, mockResponse as Response); + + expect(mockedSettingsService.getSettings).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(settings); + }); + }); + + describe('updateSettings', () => { + it('should update settings with body', async () => { + mockRequest.body = { general: { siteName: 'Updated Name' } }; + const updated = { general: { siteName: 'Updated Name' } } as any; + mockedSettingsService.updateSettings.mockResolvedValue(updated); + + await settingsController.updateSettings(mockRequest as Request, mockResponse as Response); + + expect(mockedSettingsService.updateSettings).toHaveBeenCalledWith(mockRequest.body); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(updated); + }); + }); + + describe('testEmail', () => { + it('should return test email result', async () => { + const result = { message: 'Test email sent successfully' }; + mockedSettingsService.testEmail.mockResolvedValue(result); + + await settingsController.testEmail(mockRequest as Request, mockResponse as Response); + + expect(mockedSettingsService.testEmail).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(result); + }); + }); +}); diff --git a/tests/unit/controllers/stories.controller.test.ts b/tests/unit/controllers/stories.controller.test.ts new file mode 100644 index 0000000..79a9d5d --- /dev/null +++ b/tests/unit/controllers/stories.controller.test.ts @@ -0,0 +1,150 @@ +/** + * Unit Tests for Stories Controller + */ + +import { Request, Response } from 'express'; +import { StoriesController } from '../../../src/controllers/stories.controller'; +import { storiesService } from '../../../src/services/stories.service'; +import { createMockRequest, createMockResponse, createMockSession } from '../../helpers/mocks'; + +jest.mock('../../../src/services/stories.service'); + +const mockedStoriesService = storiesService as jest.Mocked; + +describe('StoriesController', () => { + let storiesController: StoriesController; + let mockRequest: Partial; + let mockResponse: Partial; + + const sessionUser = { + id: 'user-1', + username: 'admin', + fullName: 'Admin User', + email: 'admin@test.com', + role: 'library_admin', + libraryId: 'lib-1', + }; + + beforeEach(() => { + storiesController = new StoriesController(); + mockRequest = createMockRequest(); + mockResponse = createMockResponse(); + mockRequest.session = createMockSession(sessionUser) as any; + jest.clearAllMocks(); + }); + + describe('createStory', () => { + it('should create story with libraryId from session', async () => { + mockRequest.body = { title: 'Story 1', content: 'Content' }; + const story = { id: 's1', title: 'Story 1', libraryId: 'lib-1' } as any; + mockedStoriesService.createStory.mockResolvedValue(story); + + await storiesController.createStory(mockRequest as Request, mockResponse as Response); + + expect(mockedStoriesService.createStory).toHaveBeenCalledWith( + mockRequest.body, + 'lib-1', + mockRequest.file + ); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith({ success: true, data: story }); + }); + }); + + describe('updateStory', () => { + it('should update story', async () => { + mockRequest.params = { id: 's1' }; + mockRequest.body = { title: 'Updated' }; + const updated = { id: 's1', title: 'Updated', libraryId: 'lib-1' } as any; + mockedStoriesService.updateStory.mockResolvedValue(updated); + + await storiesController.updateStory(mockRequest as Request, mockResponse as Response); + + expect(mockedStoriesService.updateStory).toHaveBeenCalledWith( + 's1', + mockRequest.body, + 'lib-1', + 'library_admin', + mockRequest.file + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ success: true, data: updated }); + }); + }); + + describe('getStory', () => { + it('should return story by id', async () => { + mockRequest.params = { id: 's1' }; + const story = { id: 's1', title: 'Story 1' } as any; + mockedStoriesService.getStory.mockResolvedValue(story); + + await storiesController.getStory(mockRequest as Request, mockResponse as Response); + + expect(mockedStoriesService.getStory).toHaveBeenCalledWith('s1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ success: true, data: story }); + }); + }); + + describe('getStories', () => { + it('should return stories with query filters', async () => { + mockRequest.query = { libraryId: 'lib-1', published: 'true', limit: '5' }; + const stories = [{ id: 's1', title: 'Story 1' }] as any[]; + mockedStoriesService.getStories.mockResolvedValue(stories); + + await storiesController.getStories(mockRequest as Request, mockResponse as Response); + + expect(mockedStoriesService.getStories).toHaveBeenCalledWith( + expect.objectContaining({ + libraryId: 'lib-1', + published: true, + limit: 5, + }) + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(stories); + }); + }); + + describe('getStoryTags', () => { + it('should return story tags', async () => { + const tags = ['history', 'art']; + mockedStoriesService.getStoryTags.mockResolvedValue(tags); + + await storiesController.getStoryTags(mockRequest as Request, mockResponse as Response); + + expect(mockedStoriesService.getStoryTags).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(tags); + }); + }); + + describe('getTimelines', () => { + it('should return timelines for story', async () => { + mockRequest.params = { id: 's1' }; + const timelines = [{ id: 't1', storyId: 's1', title: 'Timeline 1' }] as any[]; + mockedStoriesService.getTimelinesByStoryId.mockResolvedValue(timelines); + + await storiesController.getTimelines(mockRequest as Request, mockResponse as Response); + + expect(mockedStoriesService.getTimelinesByStoryId).toHaveBeenCalledWith('s1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(timelines); + }); + }); + + describe('createTimeline', () => { + it('should create timeline for story', async () => { + mockRequest.params = { id: 's1' }; + mockRequest.body = { title: 'New Timeline', year: 2020 }; + const timeline = { id: 't1', storyId: 's1', title: 'New Timeline' } as any; + mockedStoriesService.createTimeline.mockResolvedValue(timeline); + + await storiesController.createTimeline(mockRequest as Request, mockResponse as Response); + + expect(mockedStoriesService.createTimeline).toHaveBeenCalledWith('s1', mockRequest.body); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(timeline); + }); + }); +}); diff --git a/tests/unit/controllers/superadmin.controller.test.ts b/tests/unit/controllers/superadmin.controller.test.ts new file mode 100644 index 0000000..1935d32 --- /dev/null +++ b/tests/unit/controllers/superadmin.controller.test.ts @@ -0,0 +1,205 @@ +/** + * Unit Tests for SuperAdmin Controller + */ + +import { Request, Response } from 'express'; +import { SuperAdminController } from '../../../src/controllers/superadmin.controller'; +import { superAdminService } from '../../../src/services/superadmin.service'; +import { createMockRequest, createMockResponse } from '../../helpers/mocks'; + +jest.mock('../../../src/services/superadmin.service'); + +const mockedSuperAdminService = superAdminService as jest.Mocked; + +describe('SuperAdminController', () => { + let superAdminController: SuperAdminController; + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + superAdminController = new SuperAdminController(); + mockRequest = createMockRequest(); + mockResponse = createMockResponse(); + jest.clearAllMocks(); + }); + + describe('getStats', () => { + it('should return super admin stats', async () => { + const stats = { + totalLibraries: 5, + pendingLibraries: 1, + totalStories: 20, + pendingStories: 3, + totalMedia: 50, + uniqueGalleries: 10, + totalUsers: 15, + activeUsers: 12, + recentActivity: [], + }; + mockedSuperAdminService.getStats.mockResolvedValue(stats); + + await superAdminController.getStats(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.getStats).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(stats); + }); + }); + + describe('getPendingStories', () => { + it('should return pending stories', async () => { + const stories = [{ id: 's1', isApproved: false }] as any[]; + mockedSuperAdminService.getPendingStories.mockResolvedValue(stories); + + await superAdminController.getPendingStories(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.getPendingStories).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(stories); + }); + }); + + describe('getPendingMedia', () => { + it('should return pending media', async () => { + const media = [{ id: 'm1', isApproved: false }] as any[]; + mockedSuperAdminService.getPendingMedia.mockResolvedValue(media); + + await superAdminController.getPendingMedia(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.getPendingMedia).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(media); + }); + }); + + describe('approveStory', () => { + it('should approve story', async () => { + mockRequest.params = { id: 's1' }; + const story = { id: 's1', isApproved: true } as any; + mockedSuperAdminService.approveStory.mockResolvedValue(story); + + await superAdminController.approveStory(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.approveStory).toHaveBeenCalledWith('s1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(story); + }); + }); + + describe('rejectStory', () => { + it('should reject story', async () => { + mockRequest.params = { id: 's1' }; + const story = { id: 's1', isApproved: false } as any; + mockedSuperAdminService.rejectStory.mockResolvedValue(story); + + await superAdminController.rejectStory(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.rejectStory).toHaveBeenCalledWith('s1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(story); + }); + }); + + describe('approveMedia', () => { + it('should approve media', async () => { + mockRequest.params = { id: 'm1' }; + const media = { id: 'm1', isApproved: true } as any; + mockedSuperAdminService.approveMedia.mockResolvedValue(media); + + await superAdminController.approveMedia(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.approveMedia).toHaveBeenCalledWith('m1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(media); + }); + }); + + describe('rejectMedia', () => { + it('should reject media', async () => { + mockRequest.params = { id: 'm1' }; + const media = { id: 'm1', isApproved: false } as any; + mockedSuperAdminService.rejectMedia.mockResolvedValue(media); + + await superAdminController.rejectMedia(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.rejectMedia).toHaveBeenCalledWith('m1'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(media); + }); + }); + + describe('getLibraries', () => { + it('should return all libraries', async () => { + const libraries = [{ id: 'lib-1', name: 'Lib 1' }] as any[]; + mockedSuperAdminService.getLibraries.mockResolvedValue(libraries); + + await superAdminController.getLibraries(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.getLibraries).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(libraries); + }); + }); + + describe('getUsers', () => { + it('should return all users', async () => { + const users = [{ id: 'u1', username: 'admin' }] as any[]; + mockedSuperAdminService.getUsers.mockResolvedValue(users); + + await superAdminController.getUsers(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.getUsers).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(users); + }); + }); + + describe('createUser', () => { + it('should create user', async () => { + mockRequest.body = { + username: 'newuser', + password: 'secret', + email: 'u@test.com', + fullName: 'New User', + role: 'library_admin', + }; + const user = { id: 'u1', username: 'newuser' } as any; + mockedSuperAdminService.createUser.mockResolvedValue(user); + + await superAdminController.createUser(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.createUser).toHaveBeenCalledWith(mockRequest.body); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith(user); + }); + }); + + describe('updateUser', () => { + it('should update user', async () => { + mockRequest.params = { id: 'u1' }; + mockRequest.body = { fullName: 'Updated Name' }; + const user = { id: 'u1', fullName: 'Updated Name' } as any; + mockedSuperAdminService.updateUser.mockResolvedValue(user); + + await superAdminController.updateUser(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.updateUser).toHaveBeenCalledWith('u1', mockRequest.body); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith(user); + }); + }); + + describe('resetUserPassword', () => { + it('should reset user password', async () => { + mockRequest.params = { id: 'u1' }; + mockRequest.body = { password: 'newpassword' }; + mockedSuperAdminService.resetUserPassword.mockResolvedValue(undefined as any); + + await superAdminController.resetUserPassword(mockRequest as Request, mockResponse as Response); + + expect(mockedSuperAdminService.resetUserPassword).toHaveBeenCalledWith('u1', 'newpassword'); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Password reset successfully' }); + }); + }); +}); diff --git a/tests/unit/middlewares/auth.test.ts b/tests/unit/middlewares/auth.test.ts index a26beb9..4ccecef 100644 --- a/tests/unit/middlewares/auth.test.ts +++ b/tests/unit/middlewares/auth.test.ts @@ -5,7 +5,7 @@ import { Request, Response, NextFunction } from 'express'; import { requireAuth, requireRole, requireSuperAdmin, requireLibraryAdmin } from '../../../src/middlewares/auth'; import { AuthenticationError, AuthorizationError } from '../../../src/utils/errors'; -import { createMockRequest, createMockResponse, createMockNext, createMockSession } from '../../utils/mocks'; +import { createMockRequest, createMockResponse, createMockNext, createMockSession } from '../../helpers/mocks'; describe('Auth Middleware', () => { let mockRequest: Partial; @@ -21,29 +21,29 @@ describe('Auth Middleware', () => { describe('requireAuth', () => { it('should call next() when user is authenticated', () => { mockRequest.session = createMockSession(); - + requireAuth(mockRequest as Request, mockResponse as Response, mockNext); - + expect(mockNext).toHaveBeenCalledTimes(1); expect(mockNext).toHaveBeenCalledWith(); }); - it('should throw AuthenticationError when user is not authenticated', () => { + it('should call next with AuthenticationError when user is not authenticated', () => { mockRequest.session = {} as any; - - expect(() => { - requireAuth(mockRequest as Request, mockResponse as Response, mockNext); - }).toThrow(AuthenticationError); - - expect(mockNext).not.toHaveBeenCalled(); + + requireAuth(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401, message: 'Authentication required' })); }); - it('should throw AuthenticationError when session is undefined', () => { + it('should call next with AuthenticationError when session is undefined', () => { mockRequest.session = undefined; - - expect(() => { - requireAuth(mockRequest as Request, mockResponse as Response, mockNext); - }).toThrow(AuthenticationError); + + requireAuth(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401 })); }); }); @@ -54,10 +54,10 @@ describe('Auth Middleware', () => { username: 'testuser', role: 'library_admin', }); - + const middleware = requireRole('library_admin'); middleware(mockRequest as Request, mockResponse as Response, mockNext); - + expect(mockNext).toHaveBeenCalledTimes(1); expect(mockNext).toHaveBeenCalledWith(); }); @@ -68,35 +68,37 @@ describe('Auth Middleware', () => { username: 'testuser', role: 'super_admin', }); - + const middleware = requireRole('library_admin', 'super_admin'); middleware(mockRequest as Request, mockResponse as Response, mockNext); - + expect(mockNext).toHaveBeenCalledTimes(1); }); - it('should throw AuthenticationError when user is not authenticated', () => { + it('should call next with AuthenticationError when user is not authenticated', () => { mockRequest.session = {} as any; - + const middleware = requireRole('library_admin'); - - expect(() => { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - }).toThrow(AuthenticationError); + middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401 })); }); - it('should throw AuthorizationError when user does not have required role', () => { + it('should call next with AuthorizationError when user does not have required role', () => { mockRequest.session = createMockSession({ id: 'test-id', username: 'testuser', role: 'user', }); - + const middleware = requireRole('library_admin', 'super_admin'); - - expect(() => { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - }).toThrow(AuthorizationError); + middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403 })); + expect((mockNext as jest.Mock).mock.calls[0][0].message).toContain('library_admin'); + expect((mockNext as jest.Mock).mock.calls[0][0].message).toContain('super_admin'); }); it('should include required roles in error message', () => { @@ -105,17 +107,13 @@ describe('Auth Middleware', () => { username: 'testuser', role: 'user', }); - + const middleware = requireRole('library_admin', 'super_admin'); - - try { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - fail('Should have thrown AuthorizationError'); - } catch (error) { - expect(error).toBeInstanceOf(AuthorizationError); - expect((error as AuthorizationError).message).toContain('library_admin'); - expect((error as AuthorizationError).message).toContain('super_admin'); - } + middleware(mockRequest as Request, mockResponse as Response, mockNext); + + const error = (mockNext as jest.Mock).mock.calls[0][0]; + expect(error.message).toContain('library_admin'); + expect(error.message).toContain('super_admin'); }); }); @@ -126,22 +124,23 @@ describe('Auth Middleware', () => { username: 'testuser', role: 'super_admin', }); - + requireSuperAdmin(mockRequest as Request, mockResponse as Response, mockNext); - + expect(mockNext).toHaveBeenCalledTimes(1); }); - it('should throw AuthorizationError when user is not super_admin', () => { + it('should call next with AuthorizationError when user is not super_admin', () => { mockRequest.session = createMockSession({ id: 'test-id', username: 'testuser', role: 'library_admin', }); - - expect(() => { - requireSuperAdmin(mockRequest as Request, mockResponse as Response, mockNext); - }).toThrow(AuthorizationError); + + requireSuperAdmin(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403 })); }); }); @@ -152,9 +151,9 @@ describe('Auth Middleware', () => { username: 'testuser', role: 'library_admin', }); - + requireLibraryAdmin(mockRequest as Request, mockResponse as Response, mockNext); - + expect(mockNext).toHaveBeenCalledTimes(1); }); @@ -164,22 +163,23 @@ describe('Auth Middleware', () => { username: 'testuser', role: 'super_admin', }); - + requireLibraryAdmin(mockRequest as Request, mockResponse as Response, mockNext); - + expect(mockNext).toHaveBeenCalledTimes(1); }); - it('should throw AuthorizationError when user is not library_admin or super_admin', () => { + it('should call next with AuthorizationError when user is not library_admin or super_admin', () => { mockRequest.session = createMockSession({ id: 'test-id', username: 'testuser', role: 'user', }); - - expect(() => { - requireLibraryAdmin(mockRequest as Request, mockResponse as Response, mockNext); - }).toThrow(AuthorizationError); + + requireLibraryAdmin(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403 })); }); }); }); diff --git a/tests/unit/middlewares/validation.test.ts b/tests/unit/middlewares/validation.test.ts index ffef496..b937f26 100644 --- a/tests/unit/middlewares/validation.test.ts +++ b/tests/unit/middlewares/validation.test.ts @@ -6,7 +6,7 @@ import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { validate, validateQuery, validateParams } from '../../../src/middlewares/validation'; import { ValidationError } from '../../../src/utils/errors'; -import { createMockRequest, createMockResponse, createMockNext } from '../../utils/mocks'; +import { createMockRequest, createMockResponse, createMockNext } from '../../helpers/mocks'; describe('Validation Middleware', () => { let mockRequest: Partial; @@ -38,17 +38,19 @@ describe('Validation Middleware', () => { expect(mockNext).toHaveBeenCalledWith(); }); - it('should throw ValidationError when validation fails', () => { + it('should call next with ValidationError when validation fails', () => { mockRequest.body = { username: '', password: '123', }; const middleware = validate(loginSchema); + middleware(mockRequest as Request, mockResponse as Response, mockNext); - expect(() => { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - }).toThrow(ValidationError); + expect(mockNext).toHaveBeenCalledTimes(1); + const err = (mockNext as jest.Mock).mock.calls[0][0]; + expect(err.name).toBe('ValidationError'); + expect(err.statusCode).toBe(400); }); it('should format validation errors correctly', () => { @@ -58,17 +60,14 @@ describe('Validation Middleware', () => { }; const middleware = validate(loginSchema); + middleware(mockRequest as Request, mockResponse as Response, mockNext); - try { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - fail('Should have thrown ValidationError'); - } catch (error) { - expect(error).toBeInstanceOf(ValidationError); - const validationError = error as ValidationError; - expect(validationError.errors).toBeDefined(); - expect(validationError.errors).toHaveProperty('username'); - expect(validationError.errors).toHaveProperty('password'); - } + expect(mockNext).toHaveBeenCalledTimes(1); + const validationError = (mockNext as jest.Mock).mock.calls[0][0]; + expect(validationError.name).toBe('ValidationError'); + expect(validationError.errors).toBeDefined(); + expect(validationError.errors).toHaveProperty('username'); + expect(validationError.errors).toHaveProperty('password'); }); it('should handle nested validation errors', () => { @@ -87,17 +86,14 @@ describe('Validation Middleware', () => { }; const middleware = validate(nestedSchema); + middleware(mockRequest as Request, mockResponse as Response, mockNext); - try { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - fail('Should have thrown ValidationError'); - } catch (error) { - expect(error).toBeInstanceOf(ValidationError); - const validationError = error as ValidationError; - expect(validationError.errors).toBeDefined(); - expect(validationError.errors).toHaveProperty('user.name'); - expect(validationError.errors).toHaveProperty('user.email'); - } + expect(mockNext).toHaveBeenCalledTimes(1); + const validationError = (mockNext as jest.Mock).mock.calls[0][0]; + expect(validationError.name).toBe('ValidationError'); + expect(validationError.errors).toBeDefined(); + expect(Object.keys(validationError.errors || {})).toContain('user.name'); + expect(Object.keys(validationError.errors || {})).toContain('user.email'); }); it('should pass non-ZodError to next', () => { @@ -105,12 +101,10 @@ describe('Validation Middleware', () => { mockRequest.body = {}; const middleware = validate(invalidSchema); - - // This should not throw but pass error to next middleware(mockRequest as Request, mockResponse as Response, mockNext); - - // The error handling depends on implementation, but next should be called - expect(mockNext).toHaveBeenCalled(); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(expect.any(Error)); }); }); @@ -134,7 +128,7 @@ describe('Validation Middleware', () => { expect(mockNext).toHaveBeenCalledTimes(1); }); - it('should throw ValidationError when query validation fails', () => { + it('should call next with ValidationError when query validation fails', () => { const strictQuerySchema = z.object({ page: z.string().min(1, 'Page is required'), }); @@ -144,10 +138,12 @@ describe('Validation Middleware', () => { }; const middleware = validateQuery(strictQuerySchema); + middleware(mockRequest as Request, mockResponse as Response, mockNext); - expect(() => { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - }).toThrow(ValidationError); + expect(mockNext).toHaveBeenCalledTimes(1); + const err = (mockNext as jest.Mock).mock.calls[0][0]; + expect(err.name).toBe('ValidationError'); + expect(err.message).toBe('Query validation failed'); }); it('should format query validation errors correctly', () => { @@ -160,16 +156,13 @@ describe('Validation Middleware', () => { }; const middleware = validateQuery(strictQuerySchema); + middleware(mockRequest as Request, mockResponse as Response, mockNext); - try { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - fail('Should have thrown ValidationError'); - } catch (error) { - expect(error).toBeInstanceOf(ValidationError); - const validationError = error as ValidationError; - expect(validationError.message).toBe('Query validation failed'); - expect(validationError.errors).toBeDefined(); - } + expect(mockNext).toHaveBeenCalledTimes(1); + const validationError = (mockNext as jest.Mock).mock.calls[0][0]; + expect(validationError.name).toBe('ValidationError'); + expect(validationError.message).toBe('Query validation failed'); + expect(validationError.errors).toBeDefined(); }); }); @@ -189,16 +182,18 @@ describe('Validation Middleware', () => { expect(mockNext).toHaveBeenCalledTimes(1); }); - it('should throw ValidationError when params validation fails', () => { + it('should call next with ValidationError when params validation fails', () => { mockRequest.params = { id: 'invalid-id', }; const middleware = validateParams(paramsSchema); + middleware(mockRequest as Request, mockResponse as Response, mockNext); - expect(() => { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - }).toThrow(ValidationError); + expect(mockNext).toHaveBeenCalledTimes(1); + const err = (mockNext as jest.Mock).mock.calls[0][0]; + expect(err.name).toBe('ValidationError'); + expect(err.message).toBe('Parameter validation failed'); }); it('should format params validation errors correctly', () => { @@ -207,17 +202,14 @@ describe('Validation Middleware', () => { }; const middleware = validateParams(paramsSchema); + middleware(mockRequest as Request, mockResponse as Response, mockNext); - try { - middleware(mockRequest as Request, mockResponse as Response, mockNext); - fail('Should have thrown ValidationError'); - } catch (error) { - expect(error).toBeInstanceOf(ValidationError); - const validationError = error as ValidationError; - expect(validationError.message).toBe('Parameter validation failed'); - expect(validationError.errors).toBeDefined(); - expect(validationError.errors).toHaveProperty('id'); - } + expect(mockNext).toHaveBeenCalledTimes(1); + const validationError = (mockNext as jest.Mock).mock.calls[0][0]; + expect(validationError.name).toBe('ValidationError'); + expect(validationError.message).toBe('Parameter validation failed'); + expect(validationError.errors).toBeDefined(); + expect(validationError.errors).toHaveProperty('id'); }); }); }); diff --git a/tests/unit/routes/routes.test.ts b/tests/unit/routes/routes.test.ts new file mode 100644 index 0000000..0248b69 --- /dev/null +++ b/tests/unit/routes/routes.test.ts @@ -0,0 +1,230 @@ +/** + * Unit Tests for Route Registration + * + * Verifies that each route module registers the expected HTTP method and path. + */ + +import type { Express } from 'express'; + +// Mock dependencies so route modules can load without DB/auth +jest.mock('../../../src/services/drizzle-services', () => ({ + __esModule: true, + default: {}, +})); +jest.mock('../../../src/middlewares/rate-limiters', () => ({ + authLimiter: (req: any, res: any, next: any) => next(), + contactLimiter: (req: any, res: any, next: any) => next(), + emailLimiter: (req: any, res: any, next: any) => next(), + generalApiLimiter: (req: any, res: any, next: any) => next(), + adminLimiter: (req: any, res: any, next: any) => next(), + publicLimiter: (req: any, res: any, next: any) => next(), + searchLimiter: (req: any, res: any, next: any) => next(), + uploadLimiter: (req: any, res: any, next: any) => next(), +})); +jest.mock('../../../src/middlewares/auth', () => ({ + requireAuth: (req: any, res: any, next: any) => next(), + requireLibraryAdmin: (req: any, res: any, next: any) => next(), + requireSuperAdmin: (req: any, res: any, next: any) => next(), +})); +jest.mock('../../../src/middlewares/validation', () => ({ + validate: () => (req: any, res: any, next: any) => next(), +})); +jest.mock('bcrypt', () => ({ compare: jest.fn() })); +jest.mock('../../../src/services/email-service', () => ({ sendResponseEmail: jest.fn() })); +jest.mock('../../../src/validations/auth.schemas', () => ({ loginSchema: {} })); +jest.mock('../../../config/bucket-storage/cloudinary', () => ({ + cloudinaryService: { + isReady: () => false, + deleteImage: jest.fn(), + }, +})); +jest.mock('../../../src/routes/shared', () => ({ + upload: { + single: () => (req: any, res: any, next: any) => next(), + fields: () => (req: any, res: any, next: any) => next(), + }, + apiHandler: (fn: any) => fn, + uploadImageToCloudinary: jest.fn(), + jsonApiMiddleware: (req: any, res: any, next: any) => next(), +})); + +function createMockApp(): Express & { registered: Array<{ method: string; path: string }> } { + const registered: Array<{ method: string; path: string }> = []; + const noop = () => { }; + const register = (method: string) => (path: string, ...handlers: any[]) => { + registered.push({ method, path }); + }; + return { + registered, + get: register('GET'), + post: register('POST'), + patch: register('PATCH'), + put: register('PUT'), + delete: register('DELETE'), + use: noop as any, + } as Express & { registered: Array<{ method: string; path: string }> }; +} + +function hasRoute( + registered: Array<{ method: string; path: string }>, + method: string, + pathSubstr: string +): boolean { + return registered.some( + (r) => r.method === method && r.path.includes(pathSubstr) + ); +} + +const GLOBAL_PATH = '/api/v1'; + +describe('Route registration', () => { + describe('registerAuthRoutes', () => { + it('should register login, session, logout', async () => { + const { registerAuthRoutes } = await import('../../../src/routes/auth.routes'); + const app = createMockApp(); + registerAuthRoutes(app as Express, GLOBAL_PATH); + + expect(hasRoute(app.registered, 'POST', '/auth/login')).toBe(true); + expect(hasRoute(app.registered, 'GET', '/auth/session')).toBe(true); + expect(hasRoute(app.registered, 'POST', '/auth/logout')).toBe(true); + }); + }); + + describe('registerMaintenanceRoutes', () => { + it('should register health, maintenance status, toggle, schedule, backup', async () => { + const { registerMaintenanceRoutes } = await import( + '../../../src/routes/maintenance.routes' + ); + const app = createMockApp(); + registerMaintenanceRoutes(app as Express, GLOBAL_PATH); + + expect(hasRoute(app.registered, 'GET', '/health')).toBe(true); + expect(hasRoute(app.registered, 'GET', '/maintenance/status')).toBe(true); + expect(hasRoute(app.registered, 'POST', '/maintenance/toggle')).toBe(true); + expect(hasRoute(app.registered, 'POST', '/maintenance/schedule')).toBe(true); + expect(hasRoute(app.registered, 'POST', '/maintenance/backup')).toBe(true); + expect(hasRoute(app.registered, 'GET', '/maintenance/backups')).toBe(true); + expect(hasRoute(app.registered, 'POST', '/maintenance/refresh')).toBe(true); + }); + }); + + describe('registerSettingsRoutes', () => { + it('should register settings get, update, test-email', async () => { + const { registerSettingsRoutes } = await import( + '../../../src/routes/settings.routes' + ); + const app = createMockApp(); + registerSettingsRoutes(app as Express, GLOBAL_PATH); + + expect(hasRoute(app.registered, 'GET', '/settings')).toBe(true); + expect(hasRoute(app.registered, 'POST', '/settings')).toBe(true); + expect(hasRoute(app.registered, 'POST', '/settings/test-email')).toBe(true); + }); + }); + + describe('registerAdminRoutes', () => { + it('should register dashboard stats, analytics, activity, galleries, delete image', async () => { + const { registerAdminRoutes } = await import('../../../src/routes/admin.routes'); + const app = createMockApp(); + registerAdminRoutes(app as Express, GLOBAL_PATH); + + expect(app.registered.length).toBeGreaterThan(0); + const pathStr = app.registered.map((r) => r.path).join(' '); + expect(pathStr).toMatch(/admin\/dashboard\/stats/); + expect(pathStr).toMatch(/admin\/galleries|admin\/upload/); + }); + }); + + describe('registerContactRoutes', () => { + it('should run without throwing or register contact routes', async () => { + jest.resetModules(); + const app = createMockApp(); + let ok = false; + try { + const { registerContactRoutes } = await import( + '../../../src/routes/contact.routes' + ); + registerContactRoutes(app as Express, GLOBAL_PATH); + ok = true; + } catch { + // no-op: ok stays false + } + const hasRoutes = app.registered.length > 0; + expect(ok || hasRoutes).toBe(true); + }); + }); + + describe('registerLibrariesRoutes', () => { + it('should register libraries CRUD and list', async () => { + const { registerLibrariesRoutes } = await import( + '../../../src/routes/libraries.routes' + ); + const app = createMockApp(); + registerLibrariesRoutes(app as Express, GLOBAL_PATH); + + expect(app.registered.length).toBeGreaterThan(0); + const pathStr = app.registered.map((r) => r.path).join(' '); + expect(pathStr).toMatch(/libraries/); + }); + }); + + describe('registerEventsRoutes', () => { + it('should register events CRUD', async () => { + const { registerEventsRoutes } = await import( + '../../../src/routes/events.routes' + ); + const app = createMockApp(); + registerEventsRoutes(app as Express, GLOBAL_PATH); + + expect(app.registered.length).toBeGreaterThan(0); + const pathStr = app.registered.map((r) => r.path).join(' '); + expect(pathStr).toMatch(/events/); + }); + }); + + describe('registerMediaRoutes', () => { + it('should register media-items and admin media tags', async () => { + const { registerMediaRoutes } = await import( + '../../../src/routes/media.routes' + ); + const app = createMockApp(); + registerMediaRoutes(app as Express, GLOBAL_PATH); + + expect(app.registered.length).toBeGreaterThan(0); + const pathStr = app.registered.map((r) => r.path).join(' '); + expect(pathStr).toMatch(/media-items|admin\/media/); + }); + }); + + describe('registerStoriesRoutes', () => { + it('should register admin stories, stories list, tags, timelines', async () => { + const { registerStoriesRoutes } = await import( + '../../../src/routes/stories.routes' + ); + const app = createMockApp(); + registerStoriesRoutes(app as Express, GLOBAL_PATH); + + expect(app.registered.length).toBeGreaterThan(0); + const pathStr = app.registered.map((r) => r.path).join(' '); + expect(pathStr).toMatch(/stories/); + }); + }); + + describe('registerSuperAdminRoutes', () => { + it('should register sadmin stats, moderation, libraries, users', async () => { + const { registerSuperAdminRoutes } = await import( + '../../../src/routes/superadmin.routes' + ); + const app = createMockApp(); + registerSuperAdminRoutes(app as Express, GLOBAL_PATH); + + expect(hasRoute(app.registered, 'GET', '/sadmin/stats')).toBe(true); + expect(hasRoute(app.registered, 'GET', '/superadmin/moderation/stories')).toBe(true); + expect(hasRoute(app.registered, 'GET', '/superadmin/moderation/media')).toBe(true); + expect(hasRoute(app.registered, 'GET', '/superadmin/libraries')).toBe(true); + expect(hasRoute(app.registered, 'GET', '/superadmin/users')).toBe(true); + expect(hasRoute(app.registered, 'POST', '/superadmin/users')).toBe(true); + expect(hasRoute(app.registered, 'PATCH', '/superadmin/users')).toBe(true); + }); + }); +}); diff --git a/tests/unit/services/admin.service.test.ts b/tests/unit/services/admin.service.test.ts new file mode 100644 index 0000000..dfabaec --- /dev/null +++ b/tests/unit/services/admin.service.test.ts @@ -0,0 +1,85 @@ +/** + * Unit Tests for Admin Service + */ + +import { AdminService } from '../../../src/services/admin.service'; +import drizzleService from '../../../src/services/drizzle-services'; + +jest.mock('../../../src/services/drizzle-services'); +jest.mock('../../../src/middlewares/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const mockedDrizzle = drizzleService as jest.Mocked; + +describe('AdminService', () => { + let adminService: AdminService; + + beforeEach(() => { + adminService = new AdminService(); + jest.clearAllMocks(); + }); + + describe('getDashboardStats', () => { + it('should aggregate dashboard stats for library', async () => { + const stories = [ + { id: 's1', isPublished: true, libraryId: 'lib-1' }, + { id: 's2', isPublished: false, libraryId: 'lib-1' }, + ]; + const mediaItems = [ + { id: 'm1', isApproved: true, libraryId: 'lib-1' }, + { id: 'm2', isApproved: false, libraryId: 'lib-1' }, + ]; + const events = [ + { id: 'e1', eventDate: new Date(Date.now() + 86400000), libraryId: 'lib-1' }, + { id: 'e2', eventDate: new Date(Date.now() - 86400000), libraryId: 'lib-1' }, + ]; + const messages = [ + { id: 'msg1', isRead: true, libraryId: 'lib-1' }, + { id: 'msg2', isRead: false, libraryId: 'lib-1' }, + ]; + mockedDrizzle.getStories.mockResolvedValue(stories as any); + mockedDrizzle.getMediaItems.mockResolvedValue(mediaItems as any); + mockedDrizzle.getEvents.mockResolvedValue(events as any); + mockedDrizzle.getContactMessages.mockResolvedValue(messages as any); + + const result = await adminService.getDashboardStats('lib-1'); + + expect(mockedDrizzle.getStories).toHaveBeenCalledWith({ libraryId: 'lib-1' }); + expect(result).toEqual({ + totalStories: 2, + publishedStories: 1, + totalMedia: 2, + approvedMedia: 1, + totalEvents: 2, + upcomingEvents: 1, + totalMessages: 2, + unreadMessages: 1, + }); + }); + }); + + describe('getGalleries', () => { + it('should return galleries from drizzle', async () => { + const galleries = [{ id: 'g1', name: 'Gallery 1' }]; + mockedDrizzle.getGalleries.mockResolvedValue(galleries as any); + + const result = await adminService.getGalleries(); + expect(mockedDrizzle.getGalleries).toHaveBeenCalled(); + expect(result).toEqual(galleries); + }); + }); + + describe('deleteImage', () => { + it('should throw when Cloudinary not configured', async () => { + const cloudinaryMock = { isReady: jest.fn().mockReturnValue(false) }; + jest.doMock('../../../config/bucket-storage/cloudinary', () => ({ + cloudinaryService: cloudinaryMock, + })); + + await expect(adminService.deleteImage('folder/img')).rejects.toThrow( + 'Cloudinary not configured' + ); + }); + }); +}); diff --git a/tests/unit/services/auth.service.test.ts b/tests/unit/services/auth.service.test.ts new file mode 100644 index 0000000..e97bce9 --- /dev/null +++ b/tests/unit/services/auth.service.test.ts @@ -0,0 +1,122 @@ +/** + * Unit Tests for Auth Service + */ + +import { AuthService } from '../../../src/services/auth.service'; +import { compare } from 'bcrypt'; +import drizzleService from '../../../src/services/drizzle-services'; + +jest.mock('bcrypt'); +jest.mock('../../../src/services/drizzle-services'); +jest.mock('../../../src/middlewares/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const mockedCompare = compare as jest.MockedFunction; +const mockedDrizzle = drizzleService as jest.Mocked; + +describe('AuthService', () => { + let authService: AuthService; + + beforeEach(() => { + authService = new AuthService(); + jest.clearAllMocks(); + }); + + describe('authenticateUser', () => { + it('should return session user when credentials are valid', async () => { + const user = { + id: 'u1', + username: 'testuser', + password: 'hashed', + fullName: 'Test User', + email: 'test@test.com', + role: 'library_admin', + libraryId: 'lib-1', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, + }; + mockedDrizzle.getUserByUsername.mockResolvedValue(user as any); + mockedCompare.mockResolvedValue(true as never); + + const result = await authService.authenticateUser({ + username: 'testuser', + password: 'password123', + }); + + expect(mockedDrizzle.getUserByUsername).toHaveBeenCalledWith('testuser'); + expect(mockedCompare).toHaveBeenCalledWith('password123', 'hashed'); + expect(result).toEqual({ + id: 'u1', + username: 'testuser', + fullName: 'Test User', + email: 'test@test.com', + role: 'library_admin', + libraryId: 'lib-1', + }); + }); + + it('should throw AuthenticationError when user not found', async () => { + mockedDrizzle.getUserByUsername.mockResolvedValue(undefined); + + await expect( + authService.authenticateUser({ username: 'nobody', password: 'pass' }) + ).rejects.toMatchObject({ + message: 'Invalid username or password', + statusCode: 401, + name: 'AuthenticationError', + }); + expect(mockedCompare).not.toHaveBeenCalled(); + }); + + it('should throw AuthenticationError when password does not match', async () => { + const user = { + id: 'u1', + username: 'testuser', + password: 'hashed', + fullName: 'Test', + email: 't@t.com', + role: 'admin', + libraryId: 'lib-1', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, + }; + mockedDrizzle.getUserByUsername.mockResolvedValue(user as any); + mockedCompare.mockResolvedValue(false as never); + + await expect( + authService.authenticateUser({ username: 'testuser', password: 'wrong' }) + ).rejects.toMatchObject({ + message: 'Invalid username or password', + statusCode: 401, + name: 'AuthenticationError', + }); + }); + }); + + describe('getUserById', () => { + it('should return user from drizzle', async () => { + const user = { id: 'u1', username: 'test' }; + mockedDrizzle.getUser.mockResolvedValue(user as any); + + const result = await authService.getUserById('u1'); + expect(mockedDrizzle.getUser).toHaveBeenCalledWith('u1'); + expect(result).toEqual(user); + }); + }); + + describe('getUserByUsername', () => { + it('should return user from drizzle', async () => { + const user = { id: 'u1', username: 'test' }; + mockedDrizzle.getUserByUsername.mockResolvedValue(user as any); + + const result = await authService.getUserByUsername('test'); + expect(mockedDrizzle.getUserByUsername).toHaveBeenCalledWith('test'); + expect(result).toEqual(user); + }); + }); +}); diff --git a/tests/unit/services/contact.service.test.ts b/tests/unit/services/contact.service.test.ts new file mode 100644 index 0000000..098bf15 --- /dev/null +++ b/tests/unit/services/contact.service.test.ts @@ -0,0 +1,103 @@ +/** + * Unit Tests for Contact Service + */ + +import { ContactService } from '../../../src/services/contact.service'; +import drizzleService from '../../../src/services/drizzle-services'; +import { sendResponseEmail } from '../../../src/services/email-service'; + +jest.mock('../../../src/services/drizzle-services'); +jest.mock('../../../src/services/email-service'); +jest.mock('../../../src/middlewares/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const mockedDrizzle = drizzleService as jest.Mocked; +const mockedSendEmail = sendResponseEmail as jest.MockedFunction; + +describe('ContactService', () => { + let contactService: ContactService; + + beforeEach(() => { + contactService = new ContactService(); + jest.clearAllMocks(); + }); + + describe('getContactMessages', () => { + it('should return messages for libraryId when provided', async () => { + const messages = [{ id: 'm1', libraryId: 'lib-1' }]; + mockedDrizzle.getContactMessages.mockResolvedValue(messages as any); + + const result = await contactService.getContactMessages('lib-1'); + expect(mockedDrizzle.getContactMessages).toHaveBeenCalledWith({ libraryId: 'lib-1' }); + expect(result).toEqual(messages); + }); + + it('should return all messages when libraryId undefined', async () => { + mockedDrizzle.getContactMessages.mockResolvedValue([]); + await contactService.getContactMessages(undefined); + expect(mockedDrizzle.getContactMessages).toHaveBeenCalledWith({}); + }); + }); + + describe('createContactMessage', () => { + it('should create and return message', async () => { + const data = { name: 'John', email: 'j@test.com', subject: 'Hi', message: 'Hello' }; + const created = { id: 'm1', ...data }; + mockedDrizzle.createContactMessage.mockResolvedValue(created as any); + + const result = await contactService.createContactMessage(data as any); + expect(mockedDrizzle.createContactMessage).toHaveBeenCalledWith(data); + expect(result).toEqual(created); + }); + }); + + describe('updateContactMessage', () => { + it('should update and return message', async () => { + const updated = { id: 'm1', isRead: true }; + mockedDrizzle.updateContactMessage.mockResolvedValue(updated as any); + + const result = await contactService.updateContactMessage('m1', { isRead: true }); + expect(mockedDrizzle.updateContactMessage).toHaveBeenCalledWith('m1', { isRead: true }); + expect(result).toEqual(updated); + }); + + it('should throw NotFoundError when message not found', async () => { + mockedDrizzle.updateContactMessage.mockResolvedValue(undefined as any); + + await expect( + contactService.updateContactMessage('m1', { isRead: true }) + ).rejects.toMatchObject({ message: 'Contact message not found', statusCode: 404, name: 'NotFoundError' }); + }); + }); + + describe('replyToMessage', () => { + it('should throw when subject or message missing', async () => { + await expect( + contactService.replyToMessage('m1', '', 'body', 'u1', 'lib-1') + ).rejects.toThrow('Subject and message are required'); + await expect( + contactService.replyToMessage('m1', 'Sub', '', 'u1', 'lib-1') + ).rejects.toThrow('Subject and message are required'); + }); + + it('should throw NotFoundError when message not found or wrong library', async () => { + mockedDrizzle.getContactMessage.mockResolvedValue(undefined as any); + + await expect( + contactService.replyToMessage('m1', 'Re', 'Msg', 'u1', 'lib-1') + ).rejects.toMatchObject({ message: 'Message not found', statusCode: 404 }); + }); + + it('should throw when message belongs to different library', async () => { + mockedDrizzle.getContactMessage.mockResolvedValue({ + id: 'm1', + libraryId: 'other-lib', + } as any); + + await expect( + contactService.replyToMessage('m1', 'Re', 'Msg', 'u1', 'lib-1') + ).rejects.toMatchObject({ message: 'Message not found', statusCode: 404 }); + }); + }); +}); diff --git a/tests/unit/services/events.service.test.ts b/tests/unit/services/events.service.test.ts new file mode 100644 index 0000000..1eef167 --- /dev/null +++ b/tests/unit/services/events.service.test.ts @@ -0,0 +1,126 @@ +/** + * Unit Tests for Events Service + */ + +import { EventsService } from '../../../src/services/events.service'; +import drizzleService from '../../../src/services/drizzle-services'; +import * as sharedRoutes from '../../../src/routes/shared'; + +jest.mock('../../../src/services/drizzle-services'); +jest.mock('../../../src/routes/shared'); +jest.mock('../../../src/middlewares/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const mockedDrizzle = drizzleService as jest.Mocked; +const mockedUpload = sharedRoutes.uploadImageToCloudinary as jest.MockedFunction< + typeof sharedRoutes.uploadImageToCloudinary +>; + +describe('EventsService', () => { + let eventsService: EventsService; + + beforeEach(() => { + eventsService = new EventsService(); + jest.clearAllMocks(); + }); + + describe('createEvent', () => { + it('should create event with libraryId', async () => { + const data = { title: 'Event 1', eventDate: new Date('2025-12-01') }; + const created = { id: 'e1', ...data, libraryId: 'lib-1' }; + mockedDrizzle.createEvent.mockResolvedValue(created as any); + + const result = await eventsService.createEvent(data, 'lib-1'); + expect(mockedDrizzle.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + ...data, + libraryId: 'lib-1', + isApproved: false, + }) + ); + expect(result).toEqual(created); + }); + + it('should throw when libraryId missing', async () => { + await expect( + eventsService.createEvent({ title: 'E' }, '' as any) + ).rejects.toThrow('Library ID required'); + }); + + it('should upload file when provided', async () => { + const file = { buffer: Buffer.from('x'), mimetype: 'image/jpeg' } as any; + mockedUpload.mockResolvedValue('https://example.com/img.jpg'); + mockedDrizzle.createEvent.mockResolvedValue({ id: 'e1', imageUrl: 'https://example.com/img.jpg' } as any); + + await eventsService.createEvent({ title: 'E' }, 'lib-1', file); + expect(mockedUpload).toHaveBeenCalledWith(file, 'events'); + }); + }); + + describe('updateEvent', () => { + it('should throw NotFoundError when event not found', async () => { + mockedDrizzle.getEvent.mockResolvedValue(undefined as any); + + await expect( + eventsService.updateEvent('e1', {}, 'lib-1', 'library_admin') + ).rejects.toMatchObject({ + message: 'Event not found', + statusCode: 404, + }); + }); + + it('should throw AuthorizationError when library_admin edits other library event', async () => { + mockedDrizzle.getEvent.mockResolvedValue({ id: 'e1', libraryId: 'other-lib' } as any); + + await expect( + eventsService.updateEvent('e1', { title: 'X' }, 'lib-1', 'library_admin') + ).rejects.toMatchObject({ + message: 'You can only edit events for your library', + statusCode: 403, + }); + }); + + it('should update event when ownership ok', async () => { + const existing = { id: 'e1', libraryId: 'lib-1', title: 'Old' }; + const updated = { id: 'e1', libraryId: 'lib-1', title: 'New' }; + mockedDrizzle.getEvent.mockResolvedValue(existing as any); + mockedDrizzle.updateEvent.mockResolvedValue(updated as any); + + const result = await eventsService.updateEvent('e1', { title: 'New' }, 'lib-1', 'library_admin'); + expect(mockedDrizzle.updateEvent).toHaveBeenCalledWith('e1', expect.objectContaining({ title: 'New' })); + expect(result).toEqual(updated); + }); + }); + + describe('getEvents', () => { + it('should return events with optional libraryId', async () => { + const events = [{ id: 'e1', libraryId: 'lib-1' }]; + mockedDrizzle.getEvents.mockResolvedValue(events as any); + + const result = await eventsService.getEvents({ libraryId: 'lib-1' }); + expect(mockedDrizzle.getEvents).toHaveBeenCalledWith({ libraryId: 'lib-1' }); + expect(result).toEqual(events); + }); + }); + + describe('deleteEvent', () => { + it('should throw NotFoundError when event not found', async () => { + mockedDrizzle.deleteEvent.mockResolvedValue(false as any); + + await expect(eventsService.deleteEvent('e1')).rejects.toMatchObject({ + message: 'Event not found', + statusCode: 404, + name: 'NotFoundError', + }); + }); + + it('should return true when deleted', async () => { + mockedDrizzle.deleteEvent.mockResolvedValue(true as any); + + const result = await eventsService.deleteEvent('e1'); + expect(mockedDrizzle.deleteEvent).toHaveBeenCalledWith('e1'); + expect(result).toBe(true); + }); + }); +}); diff --git a/tests/unit/services/libraries.service.test.ts b/tests/unit/services/libraries.service.test.ts new file mode 100644 index 0000000..403fc24 --- /dev/null +++ b/tests/unit/services/libraries.service.test.ts @@ -0,0 +1,106 @@ +/** + * Unit Tests for Libraries Service + */ + +import { LibrariesService } from '../../../src/services/libraries.service'; +import drizzleService from '../../../src/services/drizzle-services'; +import * as sharedRoutes from '../../../src/routes/shared'; + +jest.mock('../../../src/services/drizzle-services'); +jest.mock('../../../src/routes/shared'); +jest.mock('../../../src/middlewares/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const mockedDrizzle = drizzleService as jest.Mocked; + +describe('LibrariesService', () => { + let librariesService: LibrariesService; + + beforeEach(() => { + librariesService = new LibrariesService(); + jest.clearAllMocks(); + }); + + describe('createLibrary', () => { + it('should create library with data', async () => { + const data = { name: 'Lib A', description: 'Desc' }; + const created = { id: 'lib-1', ...data }; + mockedDrizzle.createLibrary.mockResolvedValue(created as any); + + const result = await librariesService.createLibrary(data); + expect(mockedDrizzle.createLibrary).toHaveBeenCalledWith( + expect.objectContaining({ + ...data, + isApproved: false, + }) + ); + expect(result).toEqual(created); + }); + }); + + describe('updateLibrary', () => { + it('should throw NotFoundError when library not found', async () => { + mockedDrizzle.getLibrary.mockResolvedValue(undefined as any); + + await expect( + librariesService.updateLibrary('lib-1', { name: 'X' }, 'lib-1', 'library_admin') + ).rejects.toMatchObject({ message: 'Library not found', statusCode: 404, name: 'NotFoundError' }); + }); + + it('should throw AuthorizationError when library_admin updates other library', async () => { + mockedDrizzle.getLibrary.mockResolvedValue({ id: 'lib-1', libraryId: 'lib-1' } as any); + + await expect( + librariesService.updateLibrary('lib-1', { name: 'X' }, 'other-lib', 'library_admin') + ).rejects.toMatchObject({ message: 'You can only edit your own library', statusCode: 403 }); + }); + + it('should update library when allowed', async () => { + const existing = { id: 'lib-1', name: 'Old' }; + const updated = { id: 'lib-1', name: 'New' }; + mockedDrizzle.getLibrary.mockResolvedValue(existing as any); + mockedDrizzle.updateLibrary.mockResolvedValue(updated as any); + + const result = await librariesService.updateLibrary( + 'lib-1', + { name: 'New' }, + 'lib-1', + 'library_admin' + ); + expect(mockedDrizzle.updateLibrary).toHaveBeenCalled(); + expect(result).toEqual(updated); + }); + }); + + describe('getLibraries', () => { + it('should return all libraries', async () => { + const libraries = [{ id: 'lib-1', name: 'Lib 1' }]; + mockedDrizzle.getLibraries.mockResolvedValue(libraries as any); + + const result = await librariesService.getLibraries(); + expect(mockedDrizzle.getLibraries).toHaveBeenCalled(); + expect(result).toEqual(libraries); + }); + }); + + describe('getLibrary', () => { + it('should return library by id', async () => { + const library = { id: 'lib-1', name: 'Lib 1' }; + mockedDrizzle.getLibrary.mockResolvedValue(library as any); + + const result = await librariesService.getLibrary('lib-1'); + expect(mockedDrizzle.getLibrary).toHaveBeenCalledWith('lib-1'); + expect(result).toEqual(library); + }); + + it('should throw NotFoundError when not found', async () => { + mockedDrizzle.getLibrary.mockResolvedValue(undefined as any); + + await expect(librariesService.getLibrary('lib-1')).rejects.toMatchObject({ + message: 'Library not found', + statusCode: 404, + }); + }); + }); +}); diff --git a/tests/unit/services/maintenance.service.test.ts b/tests/unit/services/maintenance.service.test.ts new file mode 100644 index 0000000..b77b94a --- /dev/null +++ b/tests/unit/services/maintenance.service.test.ts @@ -0,0 +1,123 @@ +/** + * Unit Tests for Maintenance Service + */ + +import { MaintenanceService } from '../../../src/services/maintenance.service'; +import drizzleService from '../../../src/services/drizzle-services'; + +jest.mock('../../../src/services/drizzle-services'); + +const mockedDrizzle = drizzleService as jest.Mocked; + +describe('MaintenanceService', () => { + let maintenanceService: MaintenanceService; + + beforeEach(() => { + maintenanceService = new MaintenanceService(); + jest.clearAllMocks(); + }); + + describe('healthCheck', () => { + it('should return true when drizzle healthCheck succeeds', async () => { + mockedDrizzle.healthCheck.mockResolvedValue(true as any); + + const result = await maintenanceService.healthCheck(); + expect(mockedDrizzle.healthCheck).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should return false when drizzle healthCheck fails', async () => { + mockedDrizzle.healthCheck.mockResolvedValue(false as any); + + const result = await maintenanceService.healthCheck(); + expect(result).toBe(false); + }); + }); + + describe('getMaintenanceStatus', () => { + it('should return status with systemHealth and metrics', async () => { + const result = await maintenanceService.getMaintenanceStatus(); + + expect(result).toHaveProperty('maintenanceMode'); + expect(result).toHaveProperty('systemHealth'); + expect(result).toHaveProperty('systemMetrics'); + expect(result).toHaveProperty('maintenanceWindows'); + expect(result).toHaveProperty('backupHistory'); + expect(Array.isArray(result.systemHealth)).toBe(true); + expect(result.systemMetrics).toHaveProperty('cpuUsage'); + expect(result.systemMetrics).toHaveProperty('memoryUsage'); + }); + }); + + describe('toggleMaintenanceMode', () => { + it('should set maintenance mode to true', async () => { + const result = await maintenanceService.toggleMaintenanceMode(true); + expect(result).toBe(true); + }); + + it('should set maintenance mode to false', async () => { + await maintenanceService.toggleMaintenanceMode(true); + const result = await maintenanceService.toggleMaintenanceMode(false); + expect(result).toBe(false); + }); + }); + + describe('scheduleMaintenance', () => { + it('should create maintenance window', async () => { + const data = { + title: 'Upgrade', + scheduledStart: '2025-12-01T00:00:00Z', + description: 'DB upgrade', + }; + + const result = await maintenanceService.scheduleMaintenance(data); + + expect(result).toHaveProperty('id'); + expect(result.title).toBe('Upgrade'); + expect(result.description).toBe('DB upgrade'); + expect(result.status).toBe('scheduled'); + expect(result.scheduledStart).toEqual(new Date(data.scheduledStart)); + }); + + it('should throw when title or start missing', async () => { + await expect( + maintenanceService.scheduleMaintenance({} as any) + ).rejects.toThrow('Title and start time are required'); + await expect( + maintenanceService.scheduleMaintenance({ title: 'X' } as any) + ).rejects.toThrow('Title and start time are required'); + }); + }); + + describe('createBackup', () => { + it('should create backup for valid type', async () => { + const result = await maintenanceService.createBackup('database'); + + expect(result).toHaveProperty('id'); + expect(result.type).toBe('database'); + expect(result.status).toBe('running'); + expect(['database', 'files', 'full']).toContain(result.type); + }); + + it('should throw for invalid backup type', async () => { + await expect(maintenanceService.createBackup('invalid' as any)).rejects.toThrow( + 'Invalid backup type' + ); + }); + }); + + describe('getBackups', () => { + it('should return backup history', async () => { + const result = await maintenanceService.getBackups(); + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe('refreshSystemStatus', () => { + it('should return systemHealth', async () => { + const result = await maintenanceService.refreshSystemStatus(); + expect(result).toHaveProperty('systemHealth'); + expect(Array.isArray(result.systemHealth)).toBe(true); + }); + }); +}); diff --git a/tests/unit/services/media.service.test.ts b/tests/unit/services/media.service.test.ts new file mode 100644 index 0000000..810fa75 --- /dev/null +++ b/tests/unit/services/media.service.test.ts @@ -0,0 +1,123 @@ +/** + * Unit Tests for Media Service + */ + +import { MediaService } from '../../../src/services/media.service'; +import drizzleService from '../../../src/services/drizzle-services'; +import * as sharedRoutes from '../../../src/routes/shared'; + +jest.mock('../../../src/services/drizzle-services'); +jest.mock('../../../src/routes/shared'); +jest.mock('../../../src/middlewares/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const mockedDrizzle = drizzleService as jest.Mocked; + +describe('MediaService', () => { + let mediaService: MediaService; + + beforeEach(() => { + mediaService = new MediaService(); + jest.clearAllMocks(); + }); + + describe('getMediaItems', () => { + it('should return media with filters', async () => { + const items = [{ id: 'm1', libraryId: 'lib-1' }]; + mockedDrizzle.getMediaItems.mockResolvedValue(items as any); + + const result = await mediaService.getMediaItems({ libraryId: 'lib-1' }); + expect(mockedDrizzle.getMediaItems).toHaveBeenCalledWith({ libraryId: 'lib-1' }); + expect(result).toEqual(items); + }); + }); + + describe('getMediaItem', () => { + it('should return media item by id', async () => { + const item = { id: 'm1', url: 'https://example.com/img.jpg' }; + mockedDrizzle.getMediaItem.mockResolvedValue(item as any); + + const result = await mediaService.getMediaItem('m1'); + expect(mockedDrizzle.getMediaItem).toHaveBeenCalledWith('m1'); + expect(result).toEqual(item); + }); + + it('should throw NotFoundError when not found', async () => { + mockedDrizzle.getMediaItem.mockResolvedValue(undefined as any); + + await expect(mediaService.getMediaItem('m1')).rejects.toMatchObject({ + message: 'Media item not found', + statusCode: 404, + name: 'NotFoundError', + }); + }); + }); + + describe('createMediaItem', () => { + it('should throw when libraryId missing', async () => { + await expect( + mediaService.createMediaItem({ title: 'M' }, '' as any) + ).rejects.toThrow('Library ID required'); + }); + + it('should throw when url and file both missing', async () => { + await expect( + mediaService.createMediaItem({ title: 'M' }, 'lib-1') + ).rejects.toThrow('Media URL or file is required'); + }); + + it('should create with url', async () => { + const data = { title: 'Photo', url: 'https://example.com/p.jpg' }; + const created = { id: 'm1', ...data, libraryId: 'lib-1' }; + mockedDrizzle.createMediaItem.mockResolvedValue(created as any); + + const result = await mediaService.createMediaItem(data, 'lib-1'); + expect(mockedDrizzle.createMediaItem).toHaveBeenCalledWith( + expect.objectContaining({ + ...data, + libraryId: 'lib-1', + isApproved: false, + }) + ); + expect(result).toEqual(created); + }); + }); + + describe('updateMediaItem', () => { + it('should throw NotFoundError when media not found', async () => { + mockedDrizzle.getMediaItem.mockResolvedValue(undefined as any); + + await expect( + mediaService.updateMediaItem('m1', {}, 'lib-1', 'library_admin') + ).rejects.toMatchObject({ + message: 'Media item not found', + statusCode: 404, + }); + }); + + it('should throw AuthorizationError when library_admin edits other library media', async () => { + mockedDrizzle.getMediaItem.mockResolvedValue({ id: 'm1', libraryId: 'other-lib' } as any); + + await expect( + mediaService.updateMediaItem('m1', { title: 'X' }, 'lib-1', 'library_admin') + ).rejects.toMatchObject({ + message: 'You can only edit media for your library', + statusCode: 403, + }); + }); + }); + + describe('getMediaTags', () => { + it('should return sorted unique tags from all media', async () => { + const items = [ + { id: 'm1', tags: ['art', 'photo'] }, + { id: 'm2', tags: ['art', 'nature'] }, + ]; + mockedDrizzle.getMediaItems.mockResolvedValue(items as any); + + const result = await mediaService.getMediaTags(); + expect(result).toEqual(['art', 'nature', 'photo']); + }); + }); +}); diff --git a/tests/unit/services/settings.service.test.ts b/tests/unit/services/settings.service.test.ts new file mode 100644 index 0000000..a60d475 --- /dev/null +++ b/tests/unit/services/settings.service.test.ts @@ -0,0 +1,45 @@ +/** + * Unit Tests for Settings Service + */ + +import { SettingsService } from '../../../src/services/settings.service'; + +describe('SettingsService', () => { + let settingsService: SettingsService; + + beforeEach(() => { + settingsService = new SettingsService(); + }); + + describe('getSettings', () => { + it('should return platform settings', async () => { + const result = await settingsService.getSettings(); + + expect(result).toHaveProperty('general'); + expect(result).toHaveProperty('security'); + expect(result).toHaveProperty('email'); + expect(result).toHaveProperty('content'); + expect(result).toHaveProperty('appearance'); + expect(result).toHaveProperty('notifications'); + expect(result.general).toHaveProperty('siteName'); + expect(result.general).toHaveProperty('timezone'); + }); + }); + + describe('updateSettings', () => { + it('should merge and return updated settings', async () => { + const updates = { general: { siteName: 'Updated Platform Name' } }; + const result = await settingsService.updateSettings(updates as any); + + expect(result.general.siteName).toBe('Updated Platform Name'); + }); + }); + + describe('testEmail', () => { + it('should return success message', async () => { + const result = await settingsService.testEmail(); + + expect(result).toEqual({ message: 'Test email sent successfully' }); + }); + }); +}); diff --git a/tests/unit/services/stories.service.test.ts b/tests/unit/services/stories.service.test.ts new file mode 100644 index 0000000..1be9c9f --- /dev/null +++ b/tests/unit/services/stories.service.test.ts @@ -0,0 +1,173 @@ +/** + * Unit Tests for Stories Service + */ + +import { StoriesService } from '../../../src/services/stories.service'; +import drizzleService from '../../../src/services/drizzle-services'; + +jest.mock('../../../src/services/drizzle-services'); +jest.mock('../../../src/routes/shared'); +jest.mock('../../../src/middlewares/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const mockedDrizzle = drizzleService as jest.Mocked; + +describe('StoriesService', () => { + let storiesService: StoriesService; + + beforeEach(() => { + storiesService = new StoriesService(); + jest.clearAllMocks(); + }); + + describe('createStory', () => { + it('should create story with libraryId', async () => { + const data = { title: 'Story 1', content: 'Content' }; + const created = { id: 's1', ...data, libraryId: 'lib-1' }; + mockedDrizzle.createStory.mockResolvedValue(created as any); + + const result = await storiesService.createStory(data, 'lib-1'); + expect(mockedDrizzle.createStory).toHaveBeenCalledWith( + expect.objectContaining({ + ...data, + libraryId: 'lib-1', + isApproved: false, + }) + ); + expect(result).toEqual(created); + }); + }); + + describe('updateStory', () => { + it('should throw NotFoundError when story not found', async () => { + mockedDrizzle.getStory.mockResolvedValue(undefined as any); + + await expect( + storiesService.updateStory('s1', { title: 'X' }, 'lib-1', 'library_admin') + ).rejects.toMatchObject({ message: 'Story not found', statusCode: 404, name: 'NotFoundError' }); + }); + + it('should throw AuthorizationError when library_admin edits other library story', async () => { + mockedDrizzle.getStory.mockResolvedValue({ id: 's1', libraryId: 'other-lib' } as any); + + await expect( + storiesService.updateStory('s1', { title: 'X' }, 'lib-1', 'library_admin') + ).rejects.toMatchObject({ message: 'You can only edit stories for your library', statusCode: 403, name: 'AuthorizationError' }); + }); + + it('should update story when allowed', async () => { + const existing = { id: 's1', libraryId: 'lib-1', isApproved: true }; + const updated = { id: 's1', libraryId: 'lib-1', title: 'New' }; + mockedDrizzle.getStory.mockResolvedValue(existing as any); + mockedDrizzle.updateStory.mockResolvedValue(updated as any); + + const result = await storiesService.updateStory( + 's1', + { title: 'New' }, + 'lib-1', + 'library_admin' + ); + expect(mockedDrizzle.updateStory).toHaveBeenCalled(); + expect(result).toEqual(updated); + }); + }); + + describe('getStory', () => { + it('should return story by id', async () => { + const story = { id: 's1', title: 'Story 1' }; + mockedDrizzle.getStory.mockResolvedValue(story as any); + + const result = await storiesService.getStory('s1'); + expect(mockedDrizzle.getStory).toHaveBeenCalledWith('s1'); + expect(result).toEqual(story); + }); + + it('should throw NotFoundError when not found', async () => { + mockedDrizzle.getStory.mockResolvedValue(undefined as any); + + await expect(storiesService.getStory('s1')).rejects.toMatchObject({ + message: 'Story not found', + statusCode: 404, + }); + }); + }); + + describe('getStories', () => { + it('should return stories with filters', async () => { + const stories = [{ id: 's1', libraryId: 'lib-1' }]; + mockedDrizzle.getStories.mockResolvedValue(stories as any); + + const result = await storiesService.getStories({ libraryId: 'lib-1' }); + expect(mockedDrizzle.getStories).toHaveBeenCalledWith({ libraryId: 'lib-1' }); + expect(result).toEqual(stories); + }); + }); + + describe('getStoryTags', () => { + it('should return sorted unique tags from published approved stories', async () => { + const stories = [ + { id: 's1', tags: ['history', 'art'] }, + { id: 's2', tags: ['art', 'culture'] }, + ]; + mockedDrizzle.getStories.mockResolvedValue(stories as any); + + const result = await storiesService.getStoryTags(); + expect(mockedDrizzle.getStories).toHaveBeenCalledWith({ + published: true, + approved: true, + }); + expect(result).toEqual(['art', 'culture', 'history']); + }); + }); + + describe('getTimelinesByStoryId', () => { + it('should throw NotFoundError when story not found', async () => { + mockedDrizzle.getStory.mockResolvedValue(undefined as any); + + await expect(storiesService.getTimelinesByStoryId('s1')).rejects.toMatchObject({ + message: 'Story not found', + statusCode: 404, + name: 'NotFoundError', + }); + }); + + it('should return timelines when story exists', async () => { + mockedDrizzle.getStory.mockResolvedValue({ id: 's1' } as any); + const timelines = [{ id: 't1', storyId: 's1' }]; + mockedDrizzle.getTimelinesByStoryId.mockResolvedValue(timelines as any); + + const result = await storiesService.getTimelinesByStoryId('s1'); + expect(mockedDrizzle.getTimelinesByStoryId).toHaveBeenCalledWith('s1'); + expect(result).toEqual(timelines); + }); + }); + + describe('createTimeline', () => { + it('should throw NotFoundError when story not found', async () => { + mockedDrizzle.getStory.mockResolvedValue(undefined as any); + + await expect( + storiesService.createTimeline('s1', { title: 'T1' }) + ).rejects.toMatchObject({ + message: 'Story not found', + statusCode: 404, + }); + }); + + it('should create timeline when story exists', async () => { + mockedDrizzle.getStory.mockResolvedValue({ id: 's1' } as any); + const timeline = { id: 't1', storyId: 's1', title: 'T1' }; + mockedDrizzle.createTimeline.mockResolvedValue(timeline as any); + + const result = await storiesService.createTimeline('s1', { title: 'T1' }); + expect(mockedDrizzle.createTimeline).toHaveBeenCalledWith( + expect.objectContaining({ + storyId: 's1', + title: 'T1', + }) + ); + expect(result).toEqual(timeline); + }); + }); +}); diff --git a/tests/unit/services/superadmin.service.test.ts b/tests/unit/services/superadmin.service.test.ts new file mode 100644 index 0000000..8fa4b95 --- /dev/null +++ b/tests/unit/services/superadmin.service.test.ts @@ -0,0 +1,199 @@ +/** + * Unit Tests for SuperAdmin Service + */ + +import { SuperAdminService } from '../../../src/services/superadmin.service'; +import drizzleService from '../../../src/services/drizzle-services'; +import bcrypt from 'bcrypt'; + +jest.mock('../../../src/services/drizzle-services'); +jest.mock('bcrypt'); +jest.mock('../../../src/middlewares/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const mockedDrizzle = drizzleService as jest.Mocked; +const mockedBcryptHash = bcrypt.hash as jest.MockedFunction; + +describe('SuperAdminService', () => { + let superAdminService: SuperAdminService; + + beforeEach(() => { + superAdminService = new SuperAdminService(); + jest.clearAllMocks(); + mockedBcryptHash.mockResolvedValue('hashed' as never); + }); + + describe('getStats', () => { + it('should return aggregated stats', async () => { + const libraries = [ + { id: 'lib-1', isApproved: true }, + { id: 'lib-2', isApproved: false }, + ]; + const stories = [ + { id: 's1', isApproved: true }, + { id: 's2', isApproved: false }, + ]; + const mediaItems = [{ id: 'm1', galleryId: 'g1' }, { id: 'm2', galleryId: 'g2' }]; + const users = [ + { id: 'u1', lastLoginAt: new Date() }, + { id: 'u2', lastLoginAt: null }, + ]; + mockedDrizzle.getLibraries.mockResolvedValue(libraries as any); + mockedDrizzle.getStories.mockResolvedValue(stories as any); + mockedDrizzle.getMediaItems.mockResolvedValue(mediaItems as any); + mockedDrizzle.getUsersByLibraryId.mockResolvedValue(users as any); + + const result = await superAdminService.getStats(); + + expect(result.totalLibraries).toBe(2); + expect(result.pendingLibraries).toBe(1); + expect(result.totalStories).toBe(2); + expect(result.pendingStories).toBe(1); + expect(result.totalMedia).toBe(2); + expect(result.uniqueGalleries).toBe(2); + expect(result.totalUsers).toBeGreaterThanOrEqual(0); + expect(result.recentActivity).toBeDefined(); + }); + }); + + describe('getPendingStories', () => { + it('should return stories with approved false', async () => { + const stories = [{ id: 's1', isApproved: false }]; + mockedDrizzle.getStories.mockResolvedValue(stories as any); + + const result = await superAdminService.getPendingStories(); + expect(mockedDrizzle.getStories).toHaveBeenCalledWith({ approved: false }); + expect(result).toEqual(stories); + }); + }); + + describe('getPendingMedia', () => { + it('should return media with approved false', async () => { + const media = [{ id: 'm1', isApproved: false }]; + mockedDrizzle.getMediaItems.mockResolvedValue(media as any); + + const result = await superAdminService.getPendingMedia(); + expect(mockedDrizzle.getMediaItems).toHaveBeenCalledWith({ approved: false }); + expect(result).toEqual(media); + }); + }); + + describe('approveStory', () => { + it('should update story to approved', async () => { + const updated = { id: 's1', isApproved: true }; + mockedDrizzle.updateStory.mockResolvedValue(updated as any); + + const result = await superAdminService.approveStory('s1'); + expect(mockedDrizzle.updateStory).toHaveBeenCalledWith('s1', { isApproved: true }); + expect(result).toEqual(updated); + }); + + it('should throw NotFoundError when story not found', async () => { + mockedDrizzle.updateStory.mockResolvedValue(undefined as any); + + await expect(superAdminService.approveStory('s1')).rejects.toMatchObject({ + message: 'Story not found', + statusCode: 404, + }); + }); + }); + + describe('rejectStory', () => { + it('should update story to not approved', async () => { + const updated = { id: 's1', isApproved: false }; + mockedDrizzle.updateStory.mockResolvedValue(updated as any); + + const result = await superAdminService.rejectStory('s1'); + expect(mockedDrizzle.updateStory).toHaveBeenCalledWith('s1', { isApproved: false }); + expect(result).toEqual(updated); + }); + }); + + describe('createUser', () => { + it('should throw when required fields missing', async () => { + await expect( + superAdminService.createUser({ + username: '', + password: 'p', + email: 'e@e.com', + fullName: 'F', + role: 'admin', + } as any) + ).rejects.toThrow('Missing required fields'); + }); + + it('should hash password and create user', async () => { + const userData = { + username: 'newuser', + password: 'plain', + email: 'u@test.com', + fullName: 'New User', + role: 'library_admin', + }; + const created = { id: 'u1', username: 'newuser' }; + mockedDrizzle.createUser.mockResolvedValue(created as any); + + const result = await superAdminService.createUser(userData as any); + expect(mockedBcryptHash).toHaveBeenCalledWith('plain', 10); + expect(mockedDrizzle.createUser).toHaveBeenCalledWith( + expect.objectContaining({ + username: 'newuser', + password: 'hashed', + email: 'u@test.com', + fullName: 'New User', + role: 'library_admin', + }) + ); + expect(result).toEqual(created); + }); + }); + + describe('updateUser', () => { + it('should throw NotFoundError when user not found', async () => { + mockedDrizzle.updateUser.mockResolvedValue(undefined as any); + + await expect( + superAdminService.updateUser('u1', { fullName: 'X' }) + ).rejects.toMatchObject({ message: 'User not found', statusCode: 404, name: 'NotFoundError' }); + }); + + it('should update user', async () => { + const updated = { id: 'u1', fullName: 'Updated' }; + mockedDrizzle.updateUser.mockResolvedValue(updated as any); + + const result = await superAdminService.updateUser('u1', { fullName: 'Updated' }); + expect(mockedDrizzle.updateUser).toHaveBeenCalledWith('u1', { fullName: 'Updated' }); + expect(result).toEqual(updated); + }); + }); + + describe('resetUserPassword', () => { + it('should throw when password empty', async () => { + await expect( + superAdminService.resetUserPassword('u1', '') + ).rejects.toThrow('Password is required'); + }); + + it('should hash and update password', async () => { + mockedDrizzle.updateUser.mockResolvedValue({ id: 'u1' } as any); + + await superAdminService.resetUserPassword('u1', 'newpass'); + expect(mockedBcryptHash).toHaveBeenCalledWith('newpass', 10); + expect(mockedDrizzle.updateUser).toHaveBeenCalledWith('u1', { + password: 'hashed', + }); + }); + + it('should throw NotFoundError when user not found', async () => { + mockedDrizzle.updateUser.mockResolvedValue(undefined as any); + + await expect( + superAdminService.resetUserPassword('u1', 'newpass') + ).rejects.toMatchObject({ + message: 'User not found', + statusCode: 404, + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index bd9e113..363d3a0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "index.ts", "server/**/*.ts", "config/**/*.ts", - "middlewares/**/*.ts", + "src/**/*.ts", "drizzle.config.ts" ], "exclude": [ diff --git a/utils/validations.ts b/utils/validations.ts deleted file mode 100644 index ec3e8c4..0000000 --- a/utils/validations.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; - -// UUID validation middleware -interface UUIDRequest extends Request { - params: { - id: string; - [key: string]: string; - }; -} - -export const validateUUID = (req: UUIDRequest, res: Response, next: NextFunction): Response | void => { - const { id } = req.params; - if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) { - return res.status(400).json({ error: 'Invalid UUID format' }); - } - next(); -}; From 9ce16963b407c471d77ae88828b965e0bb2c8ed2 Mon Sep 17 00:00:00 2001 From: Avom Brice Date: Sat, 7 Feb 2026 04:37:21 +0100 Subject: [PATCH 3/3] docs: sync README project structure with repository --- README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 27dfc2a..10ba09a 100644 --- a/README.md +++ b/README.md @@ -87,25 +87,37 @@ This API powers a full content management system for libraries with role-based a ## Project Structure ``` +β”œβ”€β”€ .env.example # Env template (copy to .env) +β”œβ”€β”€ .github/ +β”‚ β”œβ”€β”€ workflows/ # CI (ci.yml), release (release.yml) +β”‚ └── pull_request_template.md β”œβ”€β”€ config/ β”‚ β”œβ”€β”€ bucket-storage/ # Cloudinary setup β”‚ β”œβ”€β”€ cors/ # CORS config -β”‚ β”œβ”€β”€ database/ # Schema, migrations, seed, storage +β”‚ β”œβ”€β”€ database/ # Schema, migrations, seed, storage, db β”‚ └── swagger.ts # OpenAPI spec +β”œβ”€β”€ drizzle/ # SQL migrations and meta β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ config/ # Env validation (Zod) -β”‚ β”œβ”€β”€ controllers/ # Request handlers +β”‚ β”œβ”€β”€ controllers/ # Request handlers β”‚ β”œβ”€β”€ middlewares/ # Auth, validation, error-handler, logger, rate-limiters β”‚ β”œβ”€β”€ routes/ # API route definitions β”‚ β”œβ”€β”€ services/ # Business logic (drizzle, email) -β”‚ β”œβ”€β”€ types/ # TypeScript declarations +β”‚ β”œβ”€β”€ types/ # TypeScript declarations (e.g. express.d.ts) β”‚ β”œβ”€β”€ utils/ # Errors, validations, api-response β”‚ └── validations/ # Zod request schemas β”œβ”€β”€ tests/ β”‚ β”œβ”€β”€ helpers/ # Mocks (Request, Response, session) +β”‚ β”œβ”€β”€ setup.ts β”‚ └── unit/ # Controllers, services, middlewares, routes, utils -β”œβ”€β”€ .github/workflows/ # CI pipeline +β”œβ”€β”€ scripts/ # git-flow and tooling β”œβ”€β”€ index.ts # App entry point +β”œβ”€β”€ drizzle.config.ts +β”œβ”€β”€ jest.config.js +β”œβ”€β”€ tsconfig.json +β”œβ”€β”€ package.json +β”œβ”€β”€ pnpm-lock.yaml +β”œβ”€β”€ pnpm-workspace.yaml └── render.yaml # Render deployment config ```