diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd25cd7 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +NODE_ENV=development +PORT=3001 +# Max JSON body size (bytes). Default 25MB if unset — needed for event create with base64 logo/banner. +# PAYLOAD_MAX_BYTES=26214400 +MONGODB_URI=mongodb://localhost:27017/eventbuzz +JWT_SECRET=your-super-secret-jwt-key-change-this +JWT_REFRESH_SECRET=your-refresh-secret-key-change-this +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d +# For Vite dev server use http://localhost:5173; for other frontends adjust (comma-separated for multiple). +# In NODE_ENV!==production, http://localhost:* and http://127.0.0.1:* are also allowed automatically. +FRONTEND_URL=http://localhost:5173 +CORS_ORIGIN=http://localhost:5173 +# Optional: comma-separated origins for the GrapesJS microsite builder (iframe) calling the API +# WEBSITE_BUILDER_CORS_ORIGINS=http://localhost:5173,https://builder.example.com + +# Google Sign-In (dashboard). Web client ID(s), comma-separated if multiple. +# Frontend: VITE_GOOGLE_CLIENT_ID must match the same OAuth Web client. +GOOGLE_CLIENT_ID= + +# Email +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password + +# Payment (optional) +RAZORPAY_KEY_ID= +RAZORPAY_KEY_SECRET= + +AWS_ACCESS_KEY_ID=AKIAWY2EEQXF5K4LDCI2 +AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8 +AWS_DEFAULT_REGION=ap-south-1 +AWS_BUCKET=rapportapp +AWS_URL=https://did94hhhcgq1d.cloudfront.net + +# Public API base URL injected into published microsite HTML for contact forms (e.g. https://api.example.com/api or http://localhost:3001/api) +MICROSITE_API_BASE_URL=http://localhost:3001/api + +# Firebase Realtime Database (optional — quiz/poll sync). Paths relative to backend/ when starting the API from backend/ +FIREBASE_DATABASE_URL=https://eventbuzz-live-default-rtdb.firebaseio.com +GOOGLE_APPLICATION_CREDENTIALS=./eventbuzz-live-firebase-adminsdk-fbsvc-bb3dcfd679.json +# Or if JSON lives in repo root: ../eventbuzz-live-firebase-adminsdk-fbsvc-bb3dcfd679.json +# Or: FIREBASE_SERVICE_ACCOUNT_PATH= / FIREBASE_SERVICE_ACCOUNT_JSON= (see firebase.js) \ No newline at end of file diff --git a/.gitignore b/.gitignore index b24d71e..a4a2388 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,8 @@ -# These are some examples of commonly ignored file patterns. -# You should customize this list as applicable to your project. -# Learn more about .gitignore: -# https://www.atlassian.com/git/tutorials/saving-changes/gitignore - -# Node artifact files node_modules/ -dist/ - -# Compiled Java class files -*.class - -# Compiled Python bytecode -*.py[cod] - -# Log files +.env +*-firebase-adminsdk-*.json +**/firebase-adminsdk*.json +uploads/ *.log - -# Package files -*.jar - -# Maven -target/ -dist/ - -# JetBrains IDE -.idea/ - -# Unit test reports -TEST*.xml - -# Generated by MacOS -.DS_Store - -# Generated by Windows -Thumbs.db - -# Applications -*.app -*.exe -*.war - -# Large media files -*.mp4 -*.tiff -*.avi -*.flv -*.mov -*.wmv - +coverage/ +.nyc_output/ diff --git a/README.md b/README.md index 39af52c..b7aecc3 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,74 @@ -# README # +# EventBuzz Backend -This README would normally document whatever steps are necessary to get your application up and running. +Node.js backend for EventBuzz built with **Hapi.js** and **MongoDB** (Mongoose), as per `FULL_STACK_MIGRATION_PLAN.md`. -### What is this repository for? ### +## Stack -* Quick summary -* Version -* [Learn Markdown](https://bitbucket.org/tutorials/markdowndemo) +- **Runtime:** Node.js (v18+) +- **Framework:** Hapi.js +- **Database:** MongoDB with Mongoose ODM +- **Auth:** JWT (hapi-auth-jwt2) +- **Validation:** Joi -### How do I get set up? ### +## Setup -* Summary of set up -* Configuration -* Dependencies -* Database configuration -* How to run tests -* Deployment instructions +1. **Install dependencies** + ```bash + cd backend && npm install + ``` -### Contribution guidelines ### +2. **Environment** + - Copy `.env.example` to `.env` + - Set `MONGODB_URI` (e.g. `mongodb://localhost:27017/eventbuzz`) + - Set `JWT_SECRET` and `JWT_REFRESH_SECRET` for production + - Optional **Google SSO:** set `GOOGLE_CLIENT_ID` to your OAuth 2.0 Web client ID (same value as the dashboard’s `VITE_GOOGLE_CLIENT_ID`). Add authorized JavaScript origins for each app URL in Google Cloud Console. -* Writing tests -* Code review -* Other guidelines +3. **Run** + ```bash + npm run dev + ``` + Server runs at `http://localhost:3001` (or `PORT` from `.env`). -### Who do I talk to? ### +4. **Multi-tenant backfill (after pulling schema that requires org on forms/speakers)** + Run once against your database before restart if you have legacy rows: + ```bash + npm run backfill:tenant-organization + ``` -* Repo owner or admin -* Other community or team contact \ No newline at end of file +## API Overview + +- **Auth:** `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/google`, `POST /api/auth/google/register`, `GET /api/auth/me`, etc. +- **Events:** `GET/POST /api/events`, `GET/PUT/DELETE /api/events/:id` +- **Participants:** `GET/POST /api/events/:eventId/participants`, `POST /api/participants/:id/check-in` +- **Sessions:** `GET/POST /api/events/:eventId/sessions`, `GET/PUT/DELETE /api/sessions/:id` +- **Speakers:** `GET/POST /api/speakers`, `GET /api/events/:eventId/speakers` +- **Questions:** `GET/POST /api/events/:eventId/questions`, `POST /api/questions/:id/upvote`, `POST /api/questions/:id/answer` +- **Quiz/Polls:** `GET/POST /api/events/:eventId/quiz-polls`, `POST /api/quiz-polls/:quizPollId/responses` +- **Campaigns:** `GET/POST /api/events/:eventId/campaigns`, `POST /api/campaigns/:id/send` +- **Notifications:** `GET /api/notifications`, `PUT /api/notifications/:id/read` +- **Forms:** `GET/POST /api/events/:eventId/forms`, `POST /api/forms/:formId/submit` +- **Design templates:** `GET/POST /api/design-templates`, `POST /api/design-templates/:id/use/:eventId` +- **Reward points:** `GET/POST /api/events/:eventId/reward-points`, `GET /api/events/:eventId/leaderboard` +- **Event pages:** `GET/POST /api/events/:eventId/pages`, `GET /api/pages/public/:eventSlug/:pageSlug` +- **Media:** `GET /api/media`, `POST /api/media/upload` +- **Payment:** `GET/POST /api/payment/config`, `POST /api/payment/create-order`, `POST /api/payment/verify` +- **Organization:** `GET/PUT /api/organization`, `GET/POST/PUT/DELETE /api/users` + +Protected routes expect header: `Authorization: Bearer `. + +## Project structure + +``` +backend/ +├── src/ +│ ├── config/ # database, constants +│ ├── models/ # Mongoose models (User, Event, Participant, etc.) +│ ├── controllers/ # Route handlers +│ ├── routes/ # Hapi route definitions +│ ├── middleware/ # Auth helpers +│ ├── utils/ # response helpers +│ └── app.js # Server setup +├── server.js +├── package.json +└── .env.example +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9e7ab4a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8800 @@ +{ + "name": "eventbuzz-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eventbuzz-backend", + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-s3": "^3.1012.0", + "@aws-sdk/lib-storage": "^3.1012.0", + "@hapi/hapi": "^21.3.0", + "bcrypt": "^5.1.1", + "dotenv": "^16.3.1", + "firebase-admin": "^13.0.0", + "google-auth-library": "^9.14.0", + "hapi-auth-jwt2": "^10.2.0", + "joi": "^17.13.0", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.0.0", + "nodemailer": "^6.9.7", + "otplib": "^12.0.1", + "razorpay": "^2.9.6", + "winston": "^3.11.0" + }, + "devDependencies": { + "jest": "^29.7.0", + "nodemon": "^3.0.2", + "supertest": "^6.3.3" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1012.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1012.0.tgz", + "integrity": "sha512-YB44c/NVLwyLw2x8hYSIdMFRwFJyZRuaq1HCTS2RiUWmHucSGxohuKwQdQn/XWh+NILugB+RnXrBkSqTlR3ypw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/credential-provider-node": "^3.972.22", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.1", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-sdk-s3": "^3.972.21", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.22", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.9", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.8", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.12", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-retry": "^4.4.43", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.42", + "@smithy/util-defaults-mode-node": "^4.2.45", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.21.tgz", + "integrity": "sha512-OTUcDX9Yfz/FLKbHjiMaP9D4Hs44lYJzN7zBcrK2nDmBt0Wr8D6nYt12QoBkZsW0nVMFsTIGaZCrsU9zCcIMXQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.12", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.19.tgz", + "integrity": "sha512-33NpkQtmnsjLr9QdZvL3w8bjy+WoBJ+jY8JwuzxIq38rDNi1kwpBWW7Yjh+8bMlksd+ZAWW0fH4S/6OeoAdU5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.21.tgz", + "integrity": "sha512-xFke7yjbON4unNOG0TApQwz+o1LH5VhVLgWlUuiLRWNDyBfeHIFje2ck8qHybvJ8Fkm5m3SsN+pvHtVo6PGWlQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.21.tgz", + "integrity": "sha512-fmJN7KhB7CoG65w9fC2LVOd2wZbR2d1yJIpZNe2J5CeDPu7nUHSmavuJAeGEoE3OL5UIBVPNhmK/fV/NQrs3Hw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/credential-provider-env": "^3.972.19", + "@aws-sdk/credential-provider-http": "^3.972.21", + "@aws-sdk/credential-provider-login": "^3.972.21", + "@aws-sdk/credential-provider-process": "^3.972.19", + "@aws-sdk/credential-provider-sso": "^3.972.21", + "@aws-sdk/credential-provider-web-identity": "^3.972.21", + "@aws-sdk/nested-clients": "^3.996.11", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.21.tgz", + "integrity": "sha512-ENU+YCiuQocQjfIf9bPxZ+ZY0wIBkl3SMH22optBQwy8UFpSfonHynXzGT27xQxer4cYTNOpwDqbfo57BusbpQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/nested-clients": "^3.996.11", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.22.tgz", + "integrity": "sha512-VE6i8nkmrRyhKut7nnfCWRbdDf+CfyRr8ixSwdaPDguYlgvkAO2pHu9oK11XzbSuatB0io1ozI/vpYhelXn8Pg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.19", + "@aws-sdk/credential-provider-http": "^3.972.21", + "@aws-sdk/credential-provider-ini": "^3.972.21", + "@aws-sdk/credential-provider-process": "^3.972.19", + "@aws-sdk/credential-provider-sso": "^3.972.21", + "@aws-sdk/credential-provider-web-identity": "^3.972.21", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.19.tgz", + "integrity": "sha512-hjj5bFo4kf5/WzAMjDEFByVOMbq5gZiagIpJexf7Kp9nIDaGzhCphMsx03NCA8s9zUJzHlD1lXazd7MS+e03Lg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.21.tgz", + "integrity": "sha512-9jWRCuMZpZKlqCZ46bvievqdfswsyB2yPAr9rOiN+FxaGgf8jrR5iYDqJgscvk1jrbAxiK4cIjHv3XjIAWAhzQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/nested-clients": "^3.996.11", + "@aws-sdk/token-providers": "3.1012.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.21.tgz", + "integrity": "sha512-ShWQO/cQVZ+j3zUDK7Kj+m7grPzQCVA2iaZdJ+hJTGvVH5lR32Ip/rgZZ+zBdH6D6wczP9Upa4NMXoqJdGpK1g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/nested-clients": "^3.996.11", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/lib-storage": { + "version": "3.1012.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1012.0.tgz", + "integrity": "sha512-aqfwgwxOrBDMd7SBDnaduDljAhrL9corXZrekkrxEaPLte/0OxDYRSDQCRb6JYFycEgqOY06Hi1sjzkrORTssw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/smithy-client": "^4.12.6", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.1012.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.1.tgz", + "integrity": "sha512-1MQ8czTjW8b8SpM+ZoQ0k5yD4rd19G9ALPlGgbFdRS7bwlm9ArxXWu2M22mUgSjsGJwzDkpV8e9tjUnre6adAw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz", + "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.21.tgz", + "integrity": "sha512-SXkHy8OET88y4NaSui3gMfoTpg4jHvcbAVXYJuP74vsgsJKCv/vzWM+0hVJ1W+EBOghd+qFIud80ZiuPt2RXRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.22.tgz", + "integrity": "sha512-pZPNGWZVQvgUIO/P9PXZNz7ciq9mLYb/wQEurg3phKTa3DiBIunIRcgA0eBNwmog6S3oy0KR1bv4EJ4ld9A5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.11.tgz", + "integrity": "sha512-i7SwoSR4JB/79JoGDUACnFUQOZwXGLWNX35lIb1Pq72nUGlVV+RFZp+BLa8S+mog2pbXU9+6Kc5YwGiMi5bKhQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.22", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.8", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-retry": "^4.4.43", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.42", + "@smithy/util-defaults-mode-node": "^4.2.45", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.8.tgz", + "integrity": "sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.9.tgz", + "integrity": "sha512-2aAUwudVQ3uNkCfkBLQwNVD2jkfb299NSeDueXsT2NcNdFrWtHRkiQzX3wk47UFYbm87BkdxrsAJcQO7PdQOhA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.21", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1012.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1012.0.tgz", + "integrity": "sha512-vzKwy020zjuiF4WTJzejx5nYcXJnRhHpb6i3lyZHIwfFwXG1yX4bzBVNMWYWF+bz1i2Pp2VhJbPyzpqj4VuJXQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/nested-clients": "^3.996.11", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.8.tgz", + "integrity": "sha512-Kvb96TafGPLYo4Z2GRCzQTne77epXgiZEo0DDXwavzkWmgDV/1XD1tMA766gzRcHHFUraWsE+4T8DKtPTZUxgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.22", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.13.tgz", + "integrity": "sha512-I/+BMxM4WE/6xL0tyV7tAUDOAXmyw/va1oGr/eSly43HmLUcD1G+v96vEKAA8VoLcZ03ZQo/PWzjmN9zQErqPQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@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.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.2.tgz", + "integrity": "sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.2.tgz", + "integrity": "sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.2.tgz", + "integrity": "sha512-j4A6IhVZbgxAzT6gJJC2PfOxYCK9SrDrUO7nTM4EscTYtKkAkzsbKoCnDdjFapQfnsncvPWjqVTr/0PffUwg3g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/database": "1.1.2", + "@firebase/database-types": "1.0.18", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.18.tgz", + "integrity": "sha512-yOY8IC2go9lfbVDMiy2ATun4EB2AFwocPaQADwMN/RHRUAZSM4rlAV7PGbWPSG/YhkJ2A9xQAiAENgSua9G5Fg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.15.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.0.tgz", + "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@hapi/accept": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/accept/-/accept-6.0.3.tgz", + "integrity": "sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/ammo": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/ammo/-/ammo-6.0.1.tgz", + "integrity": "sha512-pmL+nPod4g58kXrMcsGLp05O2jF4P2Q3GiL8qYV7nKYEh3cGf+rV4P5Jyi2Uq0agGhVU63GtaSAfBEZOlrJn9w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/b64": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz", + "integrity": "sha512-ZvjX4JQReUmBheeCq+S9YavcnMMHWqx3S0jHNXWIM1kQDxB9cyfSycpVvjfrKcIS8Mh5N3hmu/YKo4Iag9g2Kw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bounce": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/bounce/-/bounce-3.0.2.tgz", + "integrity": "sha512-d0XmlTi3H9HFDHhQLjg4F4auL1EY3Wqj7j7/hGDhFFe6xAbnm3qiGrXeT93zZnPH8gH+SKAFYiRzu26xkXcH3g==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/call": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@hapi/call/-/call-9.0.1.tgz", + "integrity": "sha512-uPojQRqEL1GRZR4xXPqcLMujQGaEpyVPRyBlD8Pp5rqgIwLhtveF9PkixiKru2THXvuN8mUrLeet5fqxKAAMGg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/catbox": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@hapi/catbox/-/catbox-12.1.1.tgz", + "integrity": "sha512-hDqYB1J+R0HtZg4iPH3LEnldoaBsar6bYp0EonBmNQ9t5CO+1CqgCul2ZtFveW1ReA5SQuze9GPSU7/aecERhw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/podium": "^5.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/catbox-memory": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/catbox-memory/-/catbox-memory-6.0.2.tgz", + "integrity": "sha512-H1l4ugoFW/ZRkqeFrIo8p1rWN0PA4MDTfu4JmcoNDvnY975o29mqoZblqFTotxNHlEkMPpIiIBJTV+Mbi+aF0g==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/content": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@hapi/content/-/content-6.0.0.tgz", + "integrity": "sha512-CEhs7j+H0iQffKfe5Htdak5LBOz/Qc8TRh51cF+BFv0qnuph3Em4pjGVzJMkI2gfTDdlJKWJISGWS1rK34POGA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.0" + } + }, + "node_modules/@hapi/cryptiles": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-6.0.3.tgz", + "integrity": "sha512-r6VKalpbMHz4ci3gFjFysBmhwCg70RpYZy6OkjEpdXzAYnYFX5XsW7n4YMJvuIYpnMwLxGUjK/cBhA7X3JDvXw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/file": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/file/-/file-3.0.0.tgz", + "integrity": "sha512-w+lKW+yRrLhJu620jT3y+5g2mHqnKfepreykvdOcl9/6up8GrQQn+l3FRTsjHTKbkbfQFkuksHpdv2EcpKcJ4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hapi": { + "version": "21.4.7", + "resolved": "https://registry.npmjs.org/@hapi/hapi/-/hapi-21.4.7.tgz", + "integrity": "sha512-BNO7EgGdDPUaNGhkEiaW8xqk7IqzZNDljBPLcAZgyzpAKl72YR5sBlzfQG1ncrS7O1A7NPLHJkJJgd4YYHDHfg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/accept": "^6.0.3", + "@hapi/ammo": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.2", + "@hapi/call": "^9.0.1", + "@hapi/catbox": "^12.1.1", + "@hapi/catbox-memory": "^6.0.2", + "@hapi/heavy": "^8.0.1", + "@hapi/hoek": "^11.0.7", + "@hapi/mimos": "^7.0.1", + "@hapi/podium": "^5.0.2", + "@hapi/shot": "^6.0.2", + "@hapi/somever": "^4.1.1", + "@hapi/statehood": "^8.2.1", + "@hapi/subtext": "^8.1.1", + "@hapi/teamwork": "^6.0.1", + "@hapi/topo": "^6.0.2", + "@hapi/validate": "^2.0.1" + }, + "engines": { + "node": ">=14.15.0" + } + }, + "node_modules/@hapi/heavy": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@hapi/heavy/-/heavy-8.0.1.tgz", + "integrity": "sha512-gBD/NANosNCOp6RsYTsjo2vhr5eYA3BEuogk6cxY0QdhllkkTaJFYtTXv46xd6qhBVMbMMqcSdtqey+UQU3//w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/iron": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-7.0.1.tgz", + "integrity": "sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/mimos": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/mimos/-/mimos-7.0.1.tgz", + "integrity": "sha512-b79V+BrG0gJ9zcRx1VGcCI6r6GEzzZUgiGEJVoq5gwzuB2Ig9Cax8dUuBauQCFKvl2YWSWyOc8mZ8HDaJOtkew==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "mime-db": "^1.52.0" + } + }, + "node_modules/@hapi/nigel": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/nigel/-/nigel-5.0.1.tgz", + "integrity": "sha512-uv3dtYuB4IsNaha+tigWmN8mQw/O9Qzl5U26Gm4ZcJVtDdB1AVJOwX3X5wOX+A07qzpEZnOMBAm8jjSqGsU6Nw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/vise": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/pez": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@hapi/pez/-/pez-6.1.0.tgz", + "integrity": "sha512-+FE3sFPYuXCpuVeHQ/Qag1b45clR2o54QoonE/gKHv9gukxQ8oJJZPR7o3/ydDTK6racnCJXxOyT1T93FCJMIg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/content": "^6.0.0", + "@hapi/hoek": "^11.0.2", + "@hapi/nigel": "^5.0.1" + } + }, + "node_modules/@hapi/podium": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@hapi/podium/-/podium-5.0.2.tgz", + "integrity": "sha512-T7gf2JYHQQfEfewTQFbsaXoZxSvuXO/QBIGljucUQ/lmPnTTNAepoIKOakWNVWvo2fMEDjycu77r8k6dhreqHA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/teamwork": "^6.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/shot": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/shot/-/shot-6.0.2.tgz", + "integrity": "sha512-WKK1ShfJTrL1oXC0skoIZQYzvLsyMDEF8lfcWuQBjpjCN29qivr9U36ld1z0nt6edvzv28etNMOqUF4klnHryw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/somever": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@hapi/somever/-/somever-4.1.1.tgz", + "integrity": "sha512-lt3QQiDDOVRatS0ionFDNrDIv4eXz58IibQaZQDOg4DqqdNme8oa0iPWcE0+hkq/KTeBCPtEOjDOBKBKwDumVg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/bounce": "^3.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/statehood": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@hapi/statehood/-/statehood-8.2.1.tgz", + "integrity": "sha512-xf72TG/QINW26jUu+uL5H+crE1o8GplIgfPWwPZhnAGJzetIVAQEQYvzq+C0aEVHg5/lMMtQ+L9UryuSa5Yjkg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/iron": "^7.0.1", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/subtext": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@hapi/subtext/-/subtext-8.1.1.tgz", + "integrity": "sha512-ex1Y2s/KuJktS8Ww0k6XJ5ysSKrzNym4i5pDVuCwlSgHHviHUsT1JNzE6FYhNU9TTHSNdyfue/t2m89bpkX9Jw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/content": "^6.0.0", + "@hapi/file": "^3.0.0", + "@hapi/hoek": "^11.0.2", + "@hapi/pez": "^6.1.0", + "@hapi/wreck": "^18.0.1" + } + }, + "node_modules/@hapi/teamwork": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/teamwork/-/teamwork-6.0.1.tgz", + "integrity": "sha512-52OXRslUfYwXAOG8k58f2h2ngXYQGP0x5RPOo+eWA/FtyLgHjGMrE3+e9LSXP/0q2YfHAK5wj9aA9DTy1K+kyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/validate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/validate/-/validate-2.0.1.tgz", + "integrity": "sha512-NZmXRnrSLK8MQ9y/CMqE9WSspgB9xA41/LlYR0k967aSZebWr4yNrpxIbov12ICwKy4APSlWXZga9jN5p6puPA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/topo": "^6.0.1" + } + }, + "node_modules/@hapi/vise": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/vise/-/vise-5.0.1.tgz", + "integrity": "sha512-XZYWzzRtINQLedPYlIkSkUr7m5Ddwlu99V9elh8CSygXstfv3UnWIXT0QD+wmR0VAG34d2Vx3olqcEhRRoTu9A==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/wreck": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", + "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/address/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", + "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.12.tgz", + "integrity": "sha512-XXzYyCQmyv2bISMFxu9WbB0XvUQGU0XytjXR8KBthEAWMXtVo4wxRLwZDwMtdSPq9oDWx+LpiHQbtKoLvl/szA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.12", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", + "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.26", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.26.tgz", + "integrity": "sha512-8Qfikvd2GVKSm8S6IbjfwFlRY9VlMrj0Dp4vTwAuhqbX7NhJKE5DQc2bnfJIcY0B+2YKMDBWfvexbSZeejDgeg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.43", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.43.tgz", + "integrity": "sha512-ZwsifBdyuNHrFGmbc7bAfP2b54+kt9J2rhFd18ilQGAB+GDiP4SrawqyExbB7v455QVR7Psyhb2kjULvBPIhvA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", + "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", + "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.6", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.6.tgz", + "integrity": "sha512-aib3f0jiMsJ6+cvDnXipBsGDL7ztknYSVqJs1FdN9P+u9tr/VzOR7iygSh6EUOdaBeMCMSh3N0VdyYsG4o91DQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.42", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.42.tgz", + "integrity": "sha512-0vjwmcvkWAUtikXnWIUOyV6IFHTEeQUYh3JUZcDgcszF+hD/StAsQ3rCZNZEPHgI9kVNcbnyc8P2CBHnwgmcwg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.46", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.46.tgz", + "integrity": "sha512-0uDAEJ7r8ny1e4YjK4+UB3VEG53t64ovPaQ9Zu6rXwRuXj1KXaKX6FtrPr7SmTxISQrYykShtBxEiQQViuqk/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.12", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", + "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.20", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", + "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", + "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/firebase-admin": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/firebase-admin/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/firebase-admin/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/firebase-admin/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "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", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/hapi-auth-jwt2": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/hapi-auth-jwt2/-/hapi-auth-jwt2-10.7.0.tgz", + "integrity": "sha512-jYSsgm191IpRfBPw9MvwJ2sbw/rKeo6V1w2shM+DLytHAt/EvGLwhqFkA3v3Db7sMpsBHRfSuiF4eJWMZcG0Jw==", + "license": "ISC", + "dependencies": { + "@hapi/boom": "^10.0.0", + "cookie": "^1.0.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/joi/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/joi/node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz", + "integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.20.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/razorpay": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/razorpay/-/razorpay-2.9.6.tgz", + "integrity": "sha512-zsHAQzd6e1Cc6BNoCNZQaf65ElL6O6yw0wulxmoG5VQDr363fZC90Mp1V5EktVzG45yPyNomNXWlf4cQ3622gQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar 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", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee2c75d --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "eventbuzz-backend", + "version": "1.0.0", + "description": "EventBuzz Node.js backend with Hapi and MongoDB", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "dev:nodemon": "npx nodemon server.js", + "test": "jest --coverage", + "backfill:session-join-codes": "node scripts/backfill-session-join-codes.js", + "backfill:join-codes": "node scripts/backfill-join-codes.js", + "backfill:tenant-organization": "node scripts/backfill-tenant-organization.js" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1012.0", + "@aws-sdk/lib-storage": "^3.1012.0", + "@hapi/hapi": "^21.3.0", + "bcrypt": "^5.1.1", + "dotenv": "^16.3.1", + "firebase-admin": "^13.0.0", + "google-auth-library": "^9.14.0", + "hapi-auth-jwt2": "^10.2.0", + "joi": "^17.13.0", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.0.0", + "razorpay": "^2.9.6", + "otplib": "^12.0.1", + "nodemailer": "^6.9.7", + "winston": "^3.11.0" + }, + "devDependencies": { + "jest": "^29.7.0", + "nodemon": "^3.0.2", + "supertest": "^6.3.3" + } +} diff --git a/scripts/backfill-join-codes.js b/scripts/backfill-join-codes.js new file mode 100644 index 0000000..b9b645e --- /dev/null +++ b/scripts/backfill-join-codes.js @@ -0,0 +1,45 @@ +/** + * One-off: set joinCode on all sessions and tracks (same algorithm as eventBuzzCode util). + * Run: cd backend && npm run backfill:join-codes + */ +const path = require('path'); + +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); +const mongoose = require('mongoose'); +const Session = require(path.join(__dirname, '..', 'src', 'models', 'Session')); +const Track = require(path.join(__dirname, '..', 'src', 'models', 'Track')); +const { eventBuzzCodeFor } = require(path.join(__dirname, '..', 'src', 'utils', 'eventBuzzCode')); + +async function run() { + const uri = process.env.MONGODB_URI; + if (!uri) { + console.error('MONGODB_URI is not set'); + process.exit(1); + } + await mongoose.connect(uri); + + const sessions = await Session.find({}).select('_id').lean(); + let ns = 0; + for (const s of sessions) { + const jc = eventBuzzCodeFor(s._id.toString(), 'session'); + await Session.updateOne({ _id: s._id }, { $set: { joinCode: jc } }); + ns += 1; + } + console.log(`Backfilled joinCode for ${ns} session(s).`); + + const tracks = await Track.find({}).select('_id').lean(); + let nt = 0; + for (const t of tracks) { + const jc = eventBuzzCodeFor(t._id.toString(), 'track'); + await Track.updateOne({ _id: t._id }, { $set: { joinCode: jc } }); + nt += 1; + } + console.log(`Backfilled joinCode for ${nt} track(s).`); + + await mongoose.disconnect(); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/backfill-session-join-codes.js b/scripts/backfill-session-join-codes.js new file mode 100644 index 0000000..0eca1f1 --- /dev/null +++ b/scripts/backfill-session-join-codes.js @@ -0,0 +1,33 @@ +/** + * One-off: set Session.joinCode for all existing sessions (same algorithm as dashboard eventBuzzCode). + * Run: cd backend && node scripts/backfill-session-join-codes.js + */ +const path = require('path'); + +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); +const mongoose = require('mongoose'); +const Session = require(path.join(__dirname, '..', 'src', 'models', 'Session')); +const { eventBuzzCodeFor } = require(path.join(__dirname, '..', 'src', 'utils', 'eventBuzzCode')); + +async function run() { + const uri = process.env.MONGODB_URI; + if (!uri) { + console.error('MONGODB_URI is not set'); + process.exit(1); + } + await mongoose.connect(uri); + const sessions = await Session.find({}).select('_id').lean(); + let n = 0; + for (const s of sessions) { + const jc = eventBuzzCodeFor(s._id.toString(), 'session'); + await Session.updateOne({ _id: s._id }, { $set: { joinCode: jc } }); + n += 1; + } + console.log(`Backfilled joinCode for ${n} session(s).`); + await mongoose.disconnect(); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/backfill-tenant-organization.js b/scripts/backfill-tenant-organization.js new file mode 100644 index 0000000..ace8d3b --- /dev/null +++ b/scripts/backfill-tenant-organization.js @@ -0,0 +1,89 @@ +/** + * Backfill tenant fields for multi-tenant consistency (run BEFORE deploying required schema). + * - Form.tenantOrganization from linked Event.organization + * - FormSubmission.tenantOrganization from parent Form + * - Logs Speaker rows missing organization (fix manually or delete) + * + * Run: cd backend && npm run backfill:tenant-organization + */ +const path = require('path'); + +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); +const mongoose = require('mongoose'); +const Form = require(path.join(__dirname, '..', 'src', 'models', 'Form')); +const FormSubmission = require(path.join(__dirname, '..', 'src', 'models', 'FormSubmission')); +const Event = require(path.join(__dirname, '..', 'src', 'models', 'Event')); +const Speaker = require(path.join(__dirname, '..', 'src', 'models', 'Speaker')); + +async function run() { + const uri = process.env.MONGODB_URI; + if (!uri) { + console.error('MONGODB_URI is not set'); + process.exit(1); + } + await mongoose.connect(uri); + + const formsNeedingTenant = await Form.find({ + $or: [{ tenantOrganization: { $exists: false } }, { tenantOrganization: null }], + }) + .select('_id event') + .lean(); + + let formsUpdated = 0; + for (const f of formsNeedingTenant) { + if (!f.event) { + console.warn(`Form ${f._id} has no event and no tenantOrganization — skipped`); + continue; + } + const ev = await Event.findById(f.event).select('organization').lean(); + if (!ev?.organization) { + console.warn(`Form ${f._id} event ${f.event} has no organization — skipped`); + continue; + } + await Form.updateOne({ _id: f._id }, { $set: { tenantOrganization: ev.organization } }); + formsUpdated += 1; + } + console.log(`Backfilled tenantOrganization on ${formsUpdated} form(s).`); + + const subsNeedingTenant = await FormSubmission.find({ + $or: [{ tenantOrganization: { $exists: false } }, { tenantOrganization: null }], + }) + .select('_id form') + .lean(); + + let subsUpdated = 0; + for (const s of subsNeedingTenant) { + const form = await Form.findById(s.form).select('tenantOrganization').lean(); + if (!form?.tenantOrganization) { + console.warn(`Submission ${s._id} form ${s.form} has no tenantOrganization — skipped`); + continue; + } + await FormSubmission.updateOne({ _id: s._id }, { $set: { tenantOrganization: form.tenantOrganization } }); + subsUpdated += 1; + } + console.log(`Backfilled tenantOrganization on ${subsUpdated} form submission(s).`); + + const orphanSpeakers = await Speaker.countDocuments({ + $or: [{ organization: { $exists: false } }, { organization: null }], + }); + if (orphanSpeakers > 0) { + console.warn( + `${orphanSpeakers} speaker(s) have no organization — assign org or delete before schema requires it.`, + ); + const sample = await Speaker.find({ $or: [{ organization: { $exists: false } }, { organization: null }] }) + .select('_id email firstName lastName') + .limit(10) + .lean(); + console.warn('Sample:', JSON.stringify(sample, null, 2)); + } else { + console.log('All speakers have an organization.'); + } + + await mongoose.disconnect(); + console.log('Done.'); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/migrate-event-website-to-microsites.js b/scripts/migrate-event-website-to-microsites.js new file mode 100644 index 0000000..566fbdc --- /dev/null +++ b/scripts/migrate-event-website-to-microsites.js @@ -0,0 +1,146 @@ +/** + * Copy legacy `Event.website` microsite configuration into a default `EventMicrosite` row per event. + * Sets `sourcePageId` to the event's Website Builder `home` page when present. + * + * Run: cd backend && node scripts/migrate-event-website-to-microsites.js + */ +const path = require('path'); + +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); +const mongoose = require('mongoose'); +const Event = require(path.join(__dirname, '..', 'src', 'models', 'Event')); +const EventPage = require(path.join(__dirname, '..', 'src', 'models', 'EventPage')); +const EventMicrosite = require(path.join(__dirname, '..', 'src', 'models', 'EventMicrosite')); +const User = require(path.join(__dirname, '..', 'src', 'models', 'User')); + +function normalizeFolder(raw) { + return String(raw || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, ''); +} + +function registrationFormObjectId(raw) { + if (raw == null || raw === '') return undefined; + const s = String(raw).trim(); + if (!mongoose.Types.ObjectId.isValid(s)) return undefined; + return new mongoose.Types.ObjectId(s); +} + +function websiteHasMicrositeContent(w) { + if (!w || typeof w !== 'object') return false; + if (normalizeFolder(w.folder)) return true; + if (w.enabled === true) return true; + if (typeof w.micrositeHtml === 'string' && w.micrositeHtml.trim()) return true; + if (Array.isArray(w.micrositeSections) && w.micrositeSections.length > 0) return true; + if (w.registrationFormId != null && String(w.registrationFormId).trim()) return true; + if (typeof w.title === 'string' && w.title.trim()) return true; + if (typeof w.logo === 'string' && w.logo.trim()) return true; + if (Array.isArray(w.banners) && w.banners.some(Boolean)) return true; + if (typeof w.primaryColor === 'string' && w.primaryColor.trim()) return true; + return false; +} + +async function pickCreatedBy(organizationId) { + const u = await User.findOne({ organization: organizationId }).sort({ createdAt: 1 }).select('_id').lean(); + return u?._id || null; +} + +async function uniquePublishFolder(base) { + let candidate = base || 'microsite'; + let n = 0; + // eslint-disable-next-line no-await-in-loop + while (await EventMicrosite.findOne({ publishFolder: candidate }).select('_id').lean()) { + n += 1; + candidate = `${base || 'microsite'}-${n}`; + } + return candidate; +} + +async function run() { + const uri = process.env.MONGODB_URI; + if (!uri) { + console.error('MONGODB_URI is not set'); + process.exit(1); + } + await mongoose.connect(uri); + + const events = await Event.find({}).lean(); + let created = 0; + let skipped = 0; + + for (const ev of events) { + const w = ev.website; + if (!websiteHasMicrositeContent(w)) { + skipped += 1; + continue; + } + // eslint-disable-next-line no-await-in-loop + const existing = await EventMicrosite.countDocuments({ event: ev._id }); + if (existing > 0) { + skipped += 1; + continue; + } + + // eslint-disable-next-line no-await-in-loop + const createdBy = await pickCreatedBy(ev.organization); + if (!createdBy) { + console.warn(`Event ${ev._id}: no user found for organization — skipped`); + skipped += 1; + continue; + } + + let publishFolder = normalizeFolder(w.folder); + if (!publishFolder) { + publishFolder = await uniquePublishFolder(`e${String(ev._id).slice(-8)}`); + } else { + // eslint-disable-next-line no-await-in-loop + publishFolder = await uniquePublishFolder(publishFolder); + } + + // eslint-disable-next-line no-await-in-loop + const home = await EventPage.findOne({ event: ev._id, type: 'home' }).select('_id').lean(); + + const doc = new EventMicrosite({ + event: ev._id, + name: 'Default', + publishFolder, + enabled: !!w.enabled, + logo: w.logo, + banners: Array.isArray(w.banners) ? w.banners : [], + favicon: w.favicon, + title: w.title, + seoDescription: w.seoDescription, + description: w.description, + keywords: Array.isArray(w.keywords) ? w.keywords : [], + templateId: w.templateId, + layoutTemplate: w.layoutTemplate || 'conference', + micrositeSections: Array.isArray(w.micrositeSections) ? w.micrositeSections : [], + primaryColor: w.primaryColor, + accentColor: w.accentColor, + fontPair: w.fontPair, + registrationUrl: w.registrationUrl, + registrationFormId: registrationFormObjectId(w.registrationFormId), + micrositeHtml: w.micrositeHtml, + sourcePageId: home?._id, + createdBy, + }); + + try { + // eslint-disable-next-line no-await-in-loop + await doc.save(); + created += 1; + console.log(`Created EventMicrosite ${doc._id} for event ${ev._id} (folder=${publishFolder})`); + } catch (e) { + console.error(`Event ${ev._id}: save failed`, e.message || e); + } + } + + console.log(`Done. Created ${created} microsite template(s), skipped ${skipped} event row(s) (no content or already had templates).`); + await mongoose.disconnect(); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/migrate-participants-multi-event.js b/scripts/migrate-participants-multi-event.js new file mode 100644 index 0000000..4db2fa9 --- /dev/null +++ b/scripts/migrate-participants-multi-event.js @@ -0,0 +1,180 @@ +/** + * One-time migration: legacy Participant (single `event` ref) → tenantOrganization + eventMemberships[]. + * Merges legacy rows that share the same (organization + email) into one document. + * + * Usage (from backend/): node scripts/migrate-participants-multi-event.js + */ +require('dotenv').config(); +const mongoose = require('mongoose'); +const Event = require('../src/models/Event'); + +const LEGACY_COLLECTION = 'participants'; + +function pickIdentity(doc) { + const keys = [ + 'email', + 'firstName', + 'lastName', + 'displayName', + 'phone', + 'organization', + 'designation', + 'customFields', + 'payment', + 'notes', + 'emailVerified', + ]; + const out = {}; + for (const k of keys) { + if (doc[k] !== undefined) out[k] = doc[k]; + } + return out; +} + +function mergeIdentity(base, next) { + const out = { ...base }; + for (const [k, v] of Object.entries(next)) { + if (v === undefined || v === null) continue; + if (typeof v === 'string' && !v.trim()) continue; + if (out[k] === undefined || out[k] === null || out[k] === '') { + out[k] = v; + } + } + return out; +} + +async function main() { + const uri = process.env.MONGODB_URI || process.env.MONGO_URI; + if (!uri) { + console.error('Set MONGODB_URI or MONGO_URI'); + process.exit(1); + } + await mongoose.connect(uri); + const coll = mongoose.connection.db.collection(LEGACY_COLLECTION); + + const legacy = await coll + .find({ + event: { $exists: true, $ne: null }, + $or: [{ eventMemberships: { $exists: false } }, { eventMemberships: { $size: 0 } }], + }) + .toArray(); + + console.log(`Found ${legacy.length} legacy participant documents`); + + /** @type {Map} */ + const emailGroups = new Map(); + const singles = []; + + for (const doc of legacy) { + const ev = await Event.findById(doc.event).lean(); + if (!ev?.organization) { + console.warn('Skipping participant (no event org)', String(doc._id)); + continue; + } + + const membership = { + event: doc.event, + status: doc.status || 'pending', + checkedIn: Boolean(doc.checkedIn), + checkedInAt: doc.checkedInAt || undefined, + guestToken: doc.guestToken || undefined, + registrationDate: doc.registrationDate || doc.createdAt || new Date(), + tags: Array.isArray(doc.tags) ? [...doc.tags] : [], + }; + + const email = String(doc.email || '').trim().toLowerCase(); + if (!email) { + singles.push({ + docId: doc._id, + tenantOrganization: ev.organization, + membership, + identity: pickIdentity(doc), + }); + continue; + } + + const key = `${String(ev.organization)}|${email}`; + if (!emailGroups.has(key)) { + emailGroups.set(key, { + keepId: doc._id, + docIds: [], + memberships: [], + identity: pickIdentity(doc), + }); + } + const g = emailGroups.get(key); + g.docIds.push(doc._id); + g.memberships.push(membership); + g.identity = mergeIdentity(g.identity, pickIdentity(doc)); + if (String(doc._id) < String(g.keepId)) g.keepId = doc._id; + } + + for (const [, g] of emailGroups) { + const ev0 = await Event.findById(g.memberships[0].event).lean(); + const tenantOrganization = ev0?.organization; + if (!tenantOrganization) continue; + + const byEvent = new Map(); + for (const m of g.memberships) { + byEvent.set(String(m.event), m); + } + const eventMemberships = [...byEvent.values()]; + + await coll.updateOne( + { _id: g.keepId }, + { + $set: { + ...g.identity, + email: String(g.identity.email || '').trim().toLowerCase(), + tenantOrganization, + eventMemberships, + }, + $unset: { + event: '', + status: '', + checkedIn: '', + checkedInAt: '', + guestToken: '', + registrationDate: '', + tags: '', + }, + } + ); + const others = g.docIds.filter((id) => String(id) !== String(g.keepId)); + + if (others.length) { + await coll.deleteMany({ _id: { $in: others } }); + console.log('Merged duplicates:', others.length, 'removed; kept', String(g.keepId)); + } + } + + for (const s of singles) { + await coll.updateOne( + { _id: s.docId }, + { + $set: { + ...s.identity, + tenantOrganization: s.tenantOrganization, + eventMemberships: [s.membership], + }, + $unset: { + event: '', + status: '', + checkedIn: '', + checkedInAt: '', + guestToken: '', + registrationDate: '', + tags: '', + }, + } + ); + } + + console.log('Done. Email groups:', emailGroups.size, 'guest rows:', singles.length); + await mongoose.disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..ecf7f0c --- /dev/null +++ b/server.js @@ -0,0 +1,14 @@ +require('dotenv').config(); +const start = async () => { + const app = require('./src/app'); + try { + await app.initialize(); + await app.start(); + console.log(`Server running at: ${app.info.uri}`); + } catch (err) { + console.error('Failed to start server:', err); + process.exit(1); + } +}; + +start(); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..2ec18cf --- /dev/null +++ b/src/app.js @@ -0,0 +1,247 @@ +const Hapi = require('@hapi/hapi'); +const HapiJwt = require('hapi-auth-jwt2'); +const { connectDB } = require('./config/database'); + +const authRoutes = require('./routes/auth.routes'); +const eventRoutes = require('./routes/event.routes'); +const participantRoutes = require('./routes/participant.routes'); +const participantListRoutes = require('./routes/participant-list.routes'); +const sessionRoutes = require('./routes/session.routes'); +const trackRoutes = require('./routes/track.routes'); +const speakerRoutes = require('./routes/speaker.routes'); +const questionRoutes = require('./routes/question.routes'); +const quizPollRoutes = require('./routes/quizPoll.routes'); +const campaignRoutes = require('./routes/campaign.routes'); +const notificationRoutes = require('./routes/notification.routes'); +const formRoutes = require('./routes/form.routes'); +const designTemplateRoutes = require('./routes/designTemplate.routes'); +const rewardPointRoutes = require('./routes/rewardPoint.routes'); +const pageRoutes = require('./routes/page.routes'); +const mediaRoutes = require('./routes/media.routes'); +const contentCollectionRoutes = require('./routes/contentCollection.routes'); +const paymentRoutes = require('./routes/payment.routes'); +const organizationRoutes = require('./routes/organization.routes'); +const subscriptionRoutes = require('./routes/subscription.routes'); +const adminRequestRoutes = require('./routes/adminRequest.routes'); +const eventStaffRoutes = require('./routes/eventStaff.routes'); +const websiteSettingsRoutes = require('./routes/websiteSettings.routes'); +const eventMicrositeRoutes = require('./routes/eventMicrosite.routes'); +const visitorRoutes = require('./routes/visitor.routes'); + +const createServer = async () => { + const fromEnv = (process.env.CORS_ORIGIN || process.env.FRONTEND_URL || '') + .split(',') + .map((s) => s.trim().replace(/\/$/, '')) + .filter(Boolean); + + /** Local Vite / dashboard ports — keep in sync with eventbuzz (5174) and main frontend (5173). */ + const builderExtras = (process.env.WEBSITE_BUILDER_CORS_ORIGINS || '') + .split(',') + .map((s) => s.trim().replace(/\/$/, '')) + .filter(Boolean); + + const localDevOrigins = [ + 'http://localhost:3000', + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:5175', + 'http://localhost:8080', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:5174', + 'http://127.0.0.1:5175', + 'http://127.0.0.1:8080', + ]; + + const isProd = process.env.NODE_ENV === 'production'; + // In dev, merge env with local defaults so CORS_ORIGIN=http://localhost:5173 still allows eventbuzz on :5174. + const originList = isProd + ? fromEnv.length > 0 + ? fromEnv + : localDevOrigins + : fromEnv.length > 0 + ? [...new Set([...localDevOrigins, ...fromEnv])] + : localDevOrigins; + + /** Extra allowed origins (e.g. GrapesJS app on a different port/domain). Also set WEBSITE_BUILDER_CORS_ORIGINS in prod. */ + const allowedOrigins = [...new Set([...originList, ...builderExtras])]; + + /** In development, allow any http://localhost or http://127.0.0.1 port (Vite may use 5175, 5176, …). Production: whitelist only. */ + function resolveCorsAllowOrigin(rawOrigin) { + if (!rawOrigin) return null; + const origin = String(rawOrigin).trim().replace(/\/$/, ''); + if (allowedOrigins.includes(origin)) return origin; + if (isProd) return null; + try { + const u = new URL(origin); + if (u.protocol !== 'http:') return null; + if (u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') return null; + return origin; + } catch { + return null; + } + } + + /** Hapi default is 1MB; event logo/banner are sent as base64 in JSON and easily exceed that. Override via PAYLOAD_MAX_BYTES. */ + const payloadMaxBytes = (() => { + const n = Number(process.env.PAYLOAD_MAX_BYTES); + return Number.isFinite(n) && n > 0 ? n : 25 * 1024 * 1024; + })(); + + const server = Hapi.server({ + port: process.env.PORT || 3001, + host: process.env.HOST || 'localhost', + routes: { + payload: { + maxBytes: payloadMaxBytes, + }, + validate: { + failAction: (request, h, err) => { + throw err; + }, + }, + }, + }); + + server.route({ + method: 'OPTIONS', + path: '/{p*}', + options: { + auth: false, + handler: (request, h) => { + const allowOrigin = resolveCorsAllowOrigin(request.headers.origin); + const res = h.response().code(204); + if (allowOrigin) { + res.header('Access-Control-Allow-Origin', allowOrigin); + res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.header('Access-Control-Max-Age', '86400'); + } + return res; + }, + }, + }); + + await server.register(HapiJwt); + + const User = require('./models/User'); + const jwtSecret = process.env.JWT_SECRET || 'default-secret-change-in-production'; + const attendeeSecret = process.env.ATTENDEE_JWT_SECRET || jwtSecret; + + server.auth.strategy('jwt', 'jwt', { + key: jwtSecret, + validate: async (decoded, request, h) => { + if (decoded.scope === '2fa_pending') return { isValid: false }; + const user = await User.findById(decoded.id).select('-password').populate('organization'); + if (!user || !user.isActive) return { isValid: false }; + return { isValid: true, credentials: { user, id: user._id.toString() } }; + }, + verifyOptions: { algorithms: ['HS256'] }, + }); + + const Participant = require('./models/Participant'); + server.auth.strategy('jwt-attendee', 'jwt', { + key: attendeeSecret, + validate: async (decoded, request, h) => { + if (decoded.aud !== 'attendee' || !decoded.pid) return { isValid: false }; + const participant = await Participant.findById(decoded.pid).lean(); + if (!participant) return { isValid: false }; + return { isValid: true, credentials: { participant, attendee: decoded } }; + }, + verifyOptions: { algorithms: ['HS256'] }, + }); + + server.auth.default('jwt'); + + server.route({ + method: 'GET', + path: '/', + handler: () => ({ name: 'EventBuzz API', version: '1.0.0', status: 'ok' }), + options: { auth: false }, + }); + + server.route({ + method: 'GET', + path: '/health', + handler: () => ({ status: 'ok', timestamp: new Date().toISOString() }), + options: { auth: false }, + }); + + server.route(visitorRoutes); + + server.route(authRoutes); + server.route(eventRoutes); + server.route(participantRoutes); + server.route(participantListRoutes); + server.route(sessionRoutes); + server.route(trackRoutes); + server.route(speakerRoutes); + server.route(questionRoutes); + server.route(quizPollRoutes); + server.route(campaignRoutes); + server.route(notificationRoutes); + server.route(formRoutes); + server.route(designTemplateRoutes); + server.route(rewardPointRoutes); + server.route(pageRoutes); + server.route(mediaRoutes); + server.route(contentCollectionRoutes); + server.route(paymentRoutes); + server.route(organizationRoutes); + server.route(subscriptionRoutes); + server.route(adminRequestRoutes); + server.route(eventStaffRoutes); + server.route(websiteSettingsRoutes); + server.route(eventMicrositeRoutes); + + server.ext('onPreResponse', (request, h) => { + const response = request.response; + const allowOrigin = resolveCorsAllowOrigin(request.headers.origin); + + if (response.isBoom && response.output) { + const statusCode = response.output.statusCode; + const payload = { + success: false, + error: { + code: response.output.payload.error || 'ERROR', + message: response.message, + details: response.details || [], + }, + timestamp: new Date().toISOString(), + }; + const res = h.response(payload).code(statusCode); + if (allowOrigin) { + res.header('Access-Control-Allow-Origin', allowOrigin); + res.header('Access-Control-Allow-Credentials', 'true'); + } + return res; + } + if (allowOrigin && typeof response.header === 'function') { + response.header('Access-Control-Allow-Origin', allowOrigin); + response.header('Access-Control-Allow-Credentials', 'true'); + } + return h.continue; + }); + + return server; +}; + +const app = { + _server: null, + + async initialize() { + await connectDB(); + this._server = await createServer(); + }, + + async start() { + if (!this._server) await this.initialize(); + await this._server.start(); + }, + + get info() { + return this._server ? this._server.info : null; + }, +}; + +module.exports = app; diff --git a/src/config/constants.js b/src/config/constants.js new file mode 100644 index 0000000..d098f9e --- /dev/null +++ b/src/config/constants.js @@ -0,0 +1,65 @@ +const ROLES = ['super_admin', 'admin', 'moderator', 'viewer']; +const EVENT_TYPES = ['conference', 'workshop', 'summit', 'cme', 'webinar']; +const EVENT_STATUS = ['draft', 'upcoming', 'live', 'completed']; +const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled']; +const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking']; +const SESSION_STATUS = ['scheduled', 'ongoing', 'completed', 'cancelled']; +const FORM_TYPES = ['registration', 'feedback', 'survey', 'custom', 'other']; +const FIELD_TYPES = ['text', 'email', 'phone', 'select', 'radio', 'checkbox', 'checkboxes', 'file']; +/** Registration field → Participant property mapping (or `custom` + customMapsKey). */ +const FORM_FIELD_MAPS_TO = [ + 'firstName', + 'lastName', + 'email', + 'phone', + 'organization', + 'designation', + 'notes', + 'displayName', + 'custom', +]; +const QUIZ_POLL_TYPES = ['quiz', 'poll', 'survey']; +const QUESTION_TYPES = ['single', 'multiple', 'text', 'rating']; +const CAMPAIGN_CHANNELS = ['email', 'whatsapp', 'sms']; +const CAMPAIGN_TYPES = ['broadcast', 'trigger', 'scheduled']; +const PAGE_TYPES = ['home', 'agenda', 'speakers', 'registration', 'sponsors', 'custom']; +const WEBSITE_SECTION_TYPES = [ + 'header', + 'banner', + 'contact_form', + 'agenda', + 'register_links', + 'speakers', + 'footer', + 'sponsors', + 'cta', + 'custom_html', + 'countdown', + 'gallery', + 'about', + 'venue', + 'faq', + 'tickets', +]; +const DESIGN_TEMPLATE_TYPES = ['brochure', 'microsite', 'badge', 'emailer', 'certificate']; +const REWARD_SOURCES = ['registration', 'attendance', 'feedback', 'quiz', 'referral', 'manual']; + +module.exports = { + ROLES, + EVENT_TYPES, + EVENT_STATUS, + PARTICIPANT_STATUS, + SESSION_TYPES, + SESSION_STATUS, + FORM_TYPES, + FIELD_TYPES, + FORM_FIELD_MAPS_TO, + QUIZ_POLL_TYPES, + QUESTION_TYPES, + CAMPAIGN_CHANNELS, + CAMPAIGN_TYPES, + PAGE_TYPES, + WEBSITE_SECTION_TYPES, + DESIGN_TEMPLATE_TYPES, + REWARD_SOURCES, +}; diff --git a/src/config/database.js b/src/config/database.js new file mode 100644 index 0000000..b586c39 --- /dev/null +++ b/src/config/database.js @@ -0,0 +1,13 @@ +const mongoose = require('mongoose'); + +const connectDB = async () => { + try { + const conn = await mongoose.connect(process.env.MONGODB_URI); + console.log(`MongoDB connected: ${conn.connection.host}`); + } catch (error) { + console.error('MongoDB connection error:', error); + process.exit(1); + } +}; + +module.exports = { connectDB }; diff --git a/src/config/subscriptionCatalog.js b/src/config/subscriptionCatalog.js new file mode 100644 index 0000000..d93ad8b --- /dev/null +++ b/src/config/subscriptionCatalog.js @@ -0,0 +1,59 @@ +/** + * Code-first subscription catalog. Plans reference feature keys from FEATURES. + * Align Organization.subscription.plan with PLAN ids (free | pro | enterprise). + */ +const FEATURES = { + basic_analytics: { key: 'basic_analytics', label: 'Basic analytics', description: 'Core event metrics' }, + advanced_analytics: { key: 'advanced_analytics', label: 'Advanced analytics', description: 'Deep insights' }, + custom_forms: { key: 'custom_forms', label: 'Custom forms', description: 'Advanced form builder' }, + email_support: { key: 'email_support', label: 'Email support', description: 'Standard email support' }, + priority_support: { key: 'priority_support', label: 'Priority support', description: 'Faster response' }, + white_label: { key: 'white_label', label: 'White-label', description: 'Remove EventBuzz branding' }, + api_access: { key: 'api_access', label: 'API access', description: 'REST API' }, + dedicated_support: { key: 'dedicated_support', label: 'Dedicated support', description: 'Named CSM' }, + content_library: { key: 'content_library', label: 'Content library', description: 'Portfolio media' }, +}; + +const PLANS = { + free: { + id: 'free', + name: 'Starter', + featureKeys: ['basic_analytics', 'email_support', 'content_library'], + defaultLimits: { maxEvents: 3, maxParticipants: 500 }, + }, + pro: { + id: 'pro', + name: 'Pro', + featureKeys: [ + 'basic_analytics', + 'advanced_analytics', + 'custom_forms', + 'email_support', + 'priority_support', + 'content_library', + ], + defaultLimits: { maxEvents: 20, maxParticipants: 5000 }, + }, + enterprise: { + id: 'enterprise', + name: 'Enterprise', + featureKeys: Object.keys(FEATURES), + defaultLimits: { maxEvents: null, maxParticipants: null }, + }, +}; + +function listFeatures() { + return Object.values(FEATURES); +} + +function listPlans() { + return Object.values(PLANS).map((p) => ({ + id: p.id, + name: p.name, + featureKeys: p.featureKeys, + defaultLimits: p.defaultLimits, + features: p.featureKeys.map((k) => FEATURES[k]).filter(Boolean), + })); +} + +module.exports = { FEATURES, PLANS, listFeatures, listPlans }; diff --git a/src/controllers/adminRequest.controller.js b/src/controllers/adminRequest.controller.js new file mode 100644 index 0000000..2fc1e70 --- /dev/null +++ b/src/controllers/adminRequest.controller.js @@ -0,0 +1,24 @@ +const AdminRequest = require('../models/AdminRequest'); +const { success, error } = require('../utils/response'); + +function userOrgId(user) { + return user.organization?._id || user.organization; +} + +const create = async (request, h) => { + const user = request.auth.credentials.user; + if (user.role !== 'super_admin') { + return h.response(error('FORBIDDEN', 'Only super administrators can submit these requests')).code(403); + } + const { type, message, metadata } = request.payload; + const doc = await AdminRequest.create({ + type, + requestedBy: user._id, + organization: userOrgId(user) || undefined, + message: (message || '').trim(), + metadata: metadata && typeof metadata === 'object' ? metadata : {}, + }); + return h.response(success(doc, 'Request submitted', 201)).code(201); +}; + +module.exports = { create }; diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 0000000..21d83b7 --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,450 @@ +const jwt = require('jsonwebtoken'); +let authenticator; +try { + ({ authenticator } = require('otplib')); +} catch { + authenticator = null; +} +const User = require('../models/User'); +const Organization = require('../models/Organization'); +const OrganizationUserMap = require('../models/OrganizationUserMap'); +const { verifyGoogleIdToken } = require('../utils/googleIdToken'); +const { success, error } = require('../utils/response'); + +const JWT_SECRET = process.env.JWT_SECRET || 'default-secret'; +const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'default-refresh-secret'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m'; +const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; + +const register = async (request, h) => { + try { + const { email, password, firstName, lastName, teamName } = request.payload; + const organizationName = teamName || request.payload.organizationName; + if (!organizationName || !String(organizationName).trim()) { + return h.response(error('VALIDATION_ERROR', 'Team Name is required')).code(400); + } + const existing = await User.findOne({ email }); + if (existing) { + return h.response(error('VALIDATION_ERROR', 'Email already registered')).code(400); + } + const org = await Organization.create({ name: String(organizationName).trim() }); + const user = await User.create({ + email, + password, + firstName, + lastName, + organization: org._id, + role: 'super_admin', + }); + await OrganizationUserMap.create({ + user: user._id, + organization: org._id, + role: 'super_admin', + eventAccess: [], + }); + const token = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + const refreshToken = jwt.sign({ id: user._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); + const populated = await User.findById(user._id).select('-password').populate('organization'); + return h.response(success({ + user: populated, + token, + refreshToken, + expiresIn: JWT_EXPIRES_IN, + }, 'Registration successful', 201)).code(201); + } catch (err) { + if (err.name === 'ValidationError') { + return h.response(error('VALIDATION_ERROR', err.message, Object.values(err.errors).map(e => e.message))).code(400); + } + throw err; + } +}; + +async function issueSessionForUser(h, userDoc) { + userDoc.lastLogin = new Date(); + await userDoc.save({ validateBeforeSave: false }); + const token = jwt.sign({ id: userDoc._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + const refreshToken = jwt.sign({ id: userDoc._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); + const out = userDoc.toObject ? userDoc.toObject() : { ...userDoc }; + delete out.password; + return h.response(success({ + user: out, + token, + refreshToken, + expiresIn: JWT_EXPIRES_IN, + })).code(200); +} + +const googleLogin = async (request, h) => { + try { + let g; + try { + g = await verifyGoogleIdToken(request.payload.idToken); + } catch (e) { + if (e.code === 'GOOGLE_NOT_CONFIGURED') { + return h.response(error('NOT_CONFIGURED', e.message)).code(503); + } + return h.response(error('UNAUTHORIZED', e.message || 'Invalid Google credential')).code(401); + } + let user = await User.findOne({ googleId: g.sub }).populate('organization'); + if (!user) { + user = await User.findOne({ email: g.email }).populate('organization'); + } + if (!user) { + return h.response(error('NOT_REGISTERED', 'No account for this Google user. Register first.')).code(400); + } + if (user.googleId && user.googleId !== g.sub) { + return h.response(error('ACCOUNT_MISMATCH', 'This email is linked to a different Google account.')).code(400); + } + if (!user.googleId) { + user.googleId = g.sub; + if (g.picture && !user.profileImage) user.profileImage = g.picture; + await user.save(); + user = await User.findById(user._id).populate('organization'); + } + if (!user.isActive) { + return h.response(error('FORBIDDEN', 'Account is deactivated')).code(403); + } + if (user.twoFactorEnabled) { + if (!authenticator) { + return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501); + } + const twoFactorToken = jwt.sign( + { id: user._id.toString(), scope: '2fa_pending' }, + JWT_SECRET, + { expiresIn: '5m' }, + ); + return h.response(success({ + requiresTwoFactor: true, + twoFactorToken, + email: user.email, + })).code(200); + } + return issueSessionForUser(h, user); + } catch (err) { + throw err; + } +}; + +const googleRegister = async (request, h) => { + try { + let g; + try { + g = await verifyGoogleIdToken(request.payload.idToken); + } catch (e) { + if (e.code === 'GOOGLE_NOT_CONFIGURED') { + return h.response(error('NOT_CONFIGURED', e.message)).code(503); + } + return h.response(error('UNAUTHORIZED', e.message || 'Invalid Google credential')).code(401); + } + const { teamName, firstName, lastName, phone } = request.payload; + const organizationName = teamName || request.payload.organizationName; + if (!organizationName || !String(organizationName).trim()) { + return h.response(error('VALIDATION_ERROR', 'Team Name is required')).code(400); + } + const existing = await User.findOne({ + $or: [{ email: g.email }, { googleId: g.sub }], + }); + if (existing) { + return h.response(error('VALIDATION_ERROR', 'Already registered — sign in with Google')).code(400); + } + const org = await Organization.create({ name: String(organizationName).trim() }); + const user = await User.create({ + email: g.email, + googleId: g.sub, + firstName: firstName || g.firstName, + lastName: lastName || g.lastName, + phone: phone || undefined, + profileImage: g.picture || undefined, + organization: org._id, + role: 'super_admin', + }); + await OrganizationUserMap.create({ + user: user._id, + organization: org._id, + role: 'super_admin', + eventAccess: [], + }); + const token = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + const refreshToken = jwt.sign({ id: user._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); + const populated = await User.findById(user._id).select('-password').populate('organization'); + return h.response(success({ + user: populated, + token, + refreshToken, + expiresIn: JWT_EXPIRES_IN, + }, 'Registration successful', 201)).code(201); + } catch (err) { + if (err.name === 'ValidationError') { + return h.response(error('VALIDATION_ERROR', err.message, Object.values(err.errors).map(e => e.message))).code(400); + } + throw err; + } +}; + +const login = async (request, h) => { + try { + const { email, password } = request.payload; + const user = await User.findOne({ email }).populate('organization'); + if (!user || !(await user.comparePassword(password))) { + return h.response(error('UNAUTHORIZED', 'Invalid email or password')).code(401); + } + if (!user.isActive) { + return h.response(error('FORBIDDEN', 'Account is deactivated')).code(403); + } + if (user.twoFactorEnabled) { + if (!authenticator) { + return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501); + } + const twoFactorToken = jwt.sign( + { id: user._id.toString(), scope: '2fa_pending' }, + JWT_SECRET, + { expiresIn: '5m' }, + ); + return h.response(success({ + requiresTwoFactor: true, + twoFactorToken, + email: user.email, + })).code(200); + } + return issueSessionForUser(h, user); + } catch (err) { + throw err; + } +}; + +const login2fa = async (request, h) => { + try { + if (!authenticator) { + return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501); + } + const { twoFactorToken, token: totp } = request.payload; + let decoded; + try { + decoded = jwt.verify(twoFactorToken, JWT_SECRET); + } catch { + return h.response(error('UNAUTHORIZED', 'Invalid or expired verification session. Please sign in again.')).code(401); + } + if (decoded.scope !== '2fa_pending' || !decoded.id) { + return h.response(error('UNAUTHORIZED', 'Invalid verification session')).code(401); + } + const user = await User.findById(decoded.id).select('+twoFactorSecret').populate('organization'); + if (!user || !user.isActive || !user.twoFactorEnabled || !user.twoFactorSecret) { + return h.response(error('UNAUTHORIZED', 'Invalid verification session')).code(401); + } + const ok = authenticator.verify({ + token: String(totp).replace(/\s/g, ''), + secret: user.twoFactorSecret, + }); + if (!ok) { + return h.response(error('VALIDATION_ERROR', 'Invalid authenticator code')).code(400); + } + user.lastLogin = new Date(); + await user.save({ validateBeforeSave: false }); + const accessToken = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + const refreshToken = jwt.sign({ id: user._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); + const out = user.toObject(); + delete out.password; + delete out.twoFactorSecret; + delete out.twoFactorTempSecret; + return h.response(success({ + user: out, + token: accessToken, + refreshToken, + expiresIn: JWT_EXPIRES_IN, + })).code(200); + } catch (err) { + throw err; + } +}; + +const refresh = async (request, h) => { + try { + const { refreshToken: rt } = request.payload; + if (!rt) return h.response(error('VALIDATION_ERROR', 'Refresh token required')).code(400); + const decoded = jwt.verify(rt, JWT_REFRESH_SECRET); + const user = await User.findById(decoded.id).select('-password').populate('organization'); + if (!user || !user.isActive) { + return h.response(error('UNAUTHORIZED', 'Invalid refresh token')).code(401); + } + const token = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + return h.response(success({ token, expiresIn: JWT_EXPIRES_IN })).code(200); + } catch (err) { + if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') { + return h.response(error('UNAUTHORIZED', 'Invalid or expired refresh token')).code(401); + } + throw err; + } +}; + +const me = async (request, h) => { + const user = request.auth.credentials.user; + const memberships = await OrganizationUserMap.find({ user: user._id }) + .populate('organization', 'name') + .select('organization role eventAccess') + .lean(); + const out = user.toObject ? user.toObject() : { ...user }; + delete out.twoFactorSecret; + delete out.twoFactorTempSecret; + out.twoFactorEnabled = Boolean(out.twoFactorEnabled); + out.organizationMemberships = memberships; + return h.response(success(out)).code(200); +}; + +const updateMe = async (request, h) => { + const cred = request.auth.credentials.user; + const user = await User.findById(cred._id); + if (!user) return h.response(error('NOT_FOUND', 'User not found')).code(404); + const p = request.payload; + if (p.firstName != null) user.firstName = p.firstName; + if (p.lastName != null) user.lastName = p.lastName; + if (p.phone != null) user.phone = p.phone; + if (p.profileImage != null) user.profileImage = p.profileImage; + if (p.bio != null) user.bio = String(p.bio); + if (p.notificationSettings != null && typeof p.notificationSettings === 'object') { + const cur = user.notificationSettings?.toObject?.() ?? user.notificationSettings ?? {}; + user.notificationSettings = { ...cur, ...p.notificationSettings }; + user.notificationPreferencesSavedAt = new Date(); + } + await user.save(); + const populated = await User.findById(user._id).select('-password').populate('organization'); + const memberships = await OrganizationUserMap.find({ user: user._id }) + .populate('organization', 'name') + .select('organization role eventAccess') + .lean(); + const out = populated.toObject(); + out.organizationMemberships = memberships; + return h.response(success(out)).code(200); +}; + +const changePassword = async (request, h) => { + const cred = request.auth.credentials.user; + const { currentPassword, newPassword } = request.payload; + const user = await User.findById(cred._id); + if (!user) { + return h.response(error('NOT_FOUND', 'User not found')).code(404); + } + if (!user.password) { + return h.response(error('VALIDATION_ERROR', 'Password sign-in is not set for this account. Sign in with Google, or use forgot password to set one.')).code(400); + } + if (!(await user.comparePassword(currentPassword))) { + return h.response(error('VALIDATION_ERROR', 'Current password is incorrect')).code(400); + } + user.password = newPassword; + await user.save(); + return h.response(success(null, 'Password updated')).code(200); +}; + +const setup2fa = async (request, h) => { + if (!authenticator) { + return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501); + } + const cred = request.auth.credentials.user; + const user = await User.findById(cred._id).select('+twoFactorSecret +twoFactorTempSecret'); + if (!user) return h.response(error('NOT_FOUND', 'User not found')).code(404); + if (user.twoFactorEnabled) { + return h.response(error('VALIDATION_ERROR', '2FA is already enabled')).code(400); + } + const secret = authenticator.generateSecret(); + user.twoFactorTempSecret = secret; + user.twoFactorSecret = ''; + await user.save(); + const otpauthUrl = authenticator.keyuri(user.email, 'EventBuzz', secret); + return h.response(success({ otpauthUrl, manualEntryKey: secret })).code(200); +}; + +const verify2faSetup = async (request, h) => { + if (!authenticator) { + return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501); + } + const cred = request.auth.credentials.user; + const { token } = request.payload; + const user = await User.findById(cred._id).select('+twoFactorTempSecret +twoFactorSecret'); + if (!user || !user.twoFactorTempSecret) { + return h.response(error('VALIDATION_ERROR', 'Start setup first')).code(400); + } + const ok = authenticator.verify({ token: String(token).replace(/\s/g, ''), secret: user.twoFactorTempSecret }); + if (!ok) return h.response(error('VALIDATION_ERROR', 'Invalid code')).code(400); + user.twoFactorSecret = user.twoFactorTempSecret; + user.twoFactorTempSecret = ''; + user.twoFactorEnabled = true; + await user.save(); + return h.response(success({ twoFactorEnabled: true })).code(200); +}; + +const disable2fa = async (request, h) => { + if (!authenticator) { + return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501); + } + const cred = request.auth.credentials.user; + const { currentPassword, token } = request.payload; + const user = await User.findById(cred._id).select('+password +twoFactorSecret +twoFactorTempSecret'); + if (!user) { + return h.response(error('NOT_FOUND', 'User not found')).code(404); + } + const hasPassword = Boolean(user.password); + if (hasPassword && !(await user.comparePassword(currentPassword))) { + return h.response(error('VALIDATION_ERROR', 'Current password is incorrect')).code(400); + } + if (!hasPassword) { + if (!user.twoFactorEnabled || !user.twoFactorSecret) { + return h.response(error('VALIDATION_ERROR', 'Set a password in your account before changing this security option, or contact support.')).code(400); + } + const ok = token && authenticator.verify({ token: String(token).replace(/\s/g, ''), secret: user.twoFactorSecret }); + if (!ok) return h.response(error('VALIDATION_ERROR', 'Invalid authenticator code')).code(400); + } else if (user.twoFactorEnabled && user.twoFactorSecret) { + const ok = token && authenticator.verify({ token: String(token).replace(/\s/g, ''), secret: user.twoFactorSecret }); + if (!ok) return h.response(error('VALIDATION_ERROR', 'Invalid authenticator code')).code(400); + } + user.twoFactorEnabled = false; + user.twoFactorSecret = ''; + user.twoFactorTempSecret = ''; + await user.save(); + return h.response(success(null, 'Two-factor authentication disabled')).code(200); +}; + +const forgotPassword = async (request, h) => { + const { email } = request.payload; + const user = await User.findOne({ email }); + if (!user) { + return h.response(success({ message: 'If the email exists, a reset link will be sent.' })).code(200); + } + const resetToken = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: '1h' }); + // TODO: send email with reset link using nodemailer + return h.response(success({ message: 'If the email exists, a reset link will be sent.', resetToken })).code(200); +}; + +const resetPassword = async (request, h) => { + const { token, newPassword } = request.payload; + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await User.findById(decoded.id); + if (!user) return h.response(error('UNAUTHORIZED', 'Invalid token')).code(401); + user.password = newPassword; + await user.save(); + return h.response(success(null, 'Password reset successful')).code(200); + } catch (err) { + return h.response(error('UNAUTHORIZED', 'Invalid or expired token')).code(401); + } +}; + +const logout = async (request, h) => { + return h.response(success(null, 'Logged out')).code(200); +}; + +module.exports = { + register, + login, + googleLogin, + googleRegister, + login2fa, + refresh, + me, + updateMe, + changePassword, + setup2fa, + verify2faSetup, + disable2fa, + forgotPassword, + resetPassword, + logout, +}; diff --git a/src/controllers/campaign.controller.js b/src/controllers/campaign.controller.js new file mode 100644 index 0000000..31e1b15 --- /dev/null +++ b/src/controllers/campaign.controller.js @@ -0,0 +1,113 @@ +const MessagingCampaign = require('../models/MessagingCampaign'); +const Event = require('../models/Event'); +const { success, error } = require('../utils/response'); + +const list = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const campaigns = await MessagingCampaign.find({ event: eventId }).populate('createdBy').sort({ createdAt: -1 }).lean(); + return h.response(success({ campaigns })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const campaign = await MessagingCampaign.create({ + ...request.payload, + event: eventId, + createdBy: user._id, + }); + return h.response(success(campaign, 'Campaign created', 201)).code(201); +}; + +const get = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id).populate('event createdBy'); + if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + const event = await Event.findById(campaign.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(campaign)); +}; + +const update = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id); + if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + const event = await Event.findById(campaign.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + if (campaign.status === 'sent') { + return h.response(error('VALIDATION_ERROR', 'Cannot update sent campaign')).code(400); + } + Object.assign(campaign, request.payload); + await campaign.save(); + return h.response(success(campaign)); +}; + +const remove = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id); + if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + const event = await Event.findById(campaign.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + await campaign.deleteOne(); + return h.response(success(null, 'Campaign deleted')).code(200); +}; + +const send = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id); + if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + campaign.status = 'sending'; + await campaign.save(); + // TODO: queue actual send (nodemailer / WhatsApp API) + campaign.status = 'sent'; + campaign.sentAt = new Date(); + campaign.stats = campaign.stats || { total: 0, sent: 0, delivered: 0, opened: 0, clicked: 0, failed: 0 }; + campaign.stats.sent = campaign.stats.total || 0; + await campaign.save(); + return h.response(success(campaign, 'Campaign sent')); +}; + +const schedule = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id); + if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + campaign.status = 'scheduled'; + campaign.scheduledFor = request.payload.scheduledFor; + await campaign.save(); + return h.response(success(campaign, 'Campaign scheduled')); +}; + +const cancel = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id); + if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + if (campaign.status !== 'scheduled') { + return h.response(error('VALIDATION_ERROR', 'Only scheduled campaigns can be cancelled')).code(400); + } + campaign.status = 'draft'; + campaign.scheduledFor = undefined; + await campaign.save(); + return h.response(success(campaign, 'Campaign cancelled')); +}; + +const analytics = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id).select('stats name status sentAt'); + if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + return h.response(success(campaign)); +}; + +module.exports = { list, create, get, update, remove, send, schedule, cancel, analytics }; diff --git a/src/controllers/contentCollection.controller.js b/src/controllers/contentCollection.controller.js new file mode 100644 index 0000000..6d773a6 --- /dev/null +++ b/src/controllers/contentCollection.controller.js @@ -0,0 +1,154 @@ +const mongoose = require('mongoose'); +const bcrypt = require('bcrypt'); +const ContentCollection = require('../models/ContentCollection'); +const LibraryContent = require('../models/LibraryContent'); +const { success, error } = require('../utils/response'); +const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); + +function userOrgId(user) { + return user.organization?._id || user.organization; +} + +function assertCollectionOrg(doc, user) { + if (user.role === 'super_admin') return true; + const uo = userOrgId(user); + return uo && doc.organization?.toString() === uo.toString(); +} + +const list = async (request, h) => { + const user = request.auth.credentials.user; + const scoped = resolveScopedOrganizationId(user, request.query.organization); + if (scoped.error) return h.response(error(scoped.error.code, scoped.error.message)).code(400); + const filter = { organization: scoped.organizationId }; + const { page = 1, limit = 50 } = request.query; + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); + const [collections, total] = await Promise.all([ + ContentCollection.find(filter).sort({ createdAt: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(), + ContentCollection.countDocuments(filter), + ]); + return h.response(success({ collections, total, page: parseInt(page, 10), limit: parseInt(limit, 10) })); +}; + +const create = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = userOrgId(user); + if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + + const p = request.payload; + let passwordHash = ''; + if (p.passwordProtected && p.password && String(p.password).trim()) { + passwordHash = await bcrypt.hash(String(p.password).trim(), 10); + } + + const contentIds = (p.contentIds || []) + .map((id) => (mongoose.Types.ObjectId.isValid(id) ? new mongoose.Types.ObjectId(id) : null)) + .filter(Boolean); + + if (contentIds.length) { + const n = await LibraryContent.countDocuments({ + _id: { $in: contentIds }, + organization: orgId, + }); + if (n !== contentIds.length) { + return h.response(error('VALIDATION_ERROR', 'One or more library files are invalid or not in your organization')).code(400); + } + } + + const sharedWith = (p.sharedWith || []) + .map((id) => (mongoose.Types.ObjectId.isValid(id) ? new mongoose.Types.ObjectId(id) : null)) + .filter(Boolean); + + const doc = await ContentCollection.create({ + organization: orgId, + name: p.name.trim(), + description: (p.description || '').trim(), + collectionType: p.collectionType || '', + coverIcon: p.coverIcon || '', + tags: Array.isArray(p.tags) ? p.tags.map((t) => String(t).trim()).filter(Boolean) : [], + contentIds, + visibility: p.visibility || 'private', + sharedWith, + passwordProtected: Boolean(p.passwordProtected), + passwordHash, + expiryEnabled: Boolean(p.expiryEnabled), + expiryDate: p.expiryDate ? new Date(p.expiryDate) : undefined, + notifyOnAdd: p.notifyOnAdd !== false, + notifyOnShare: p.notifyOnShare !== false, + createdBy: user._id, + }); + + return h.response(success(doc, 'Collection created', 201)).code(201); +}; + +const get = async (request, h) => { + const doc = await ContentCollection.findById(request.params.id).lean(); + if (!doc) return h.response(error('NOT_FOUND', 'Collection not found')).code(404); + const user = request.auth.credentials.user; + if (!assertCollectionOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + const out = { ...doc }; + delete out.passwordHash; + return h.response(success(out)); +}; + +const update = async (request, h) => { + const doc = await ContentCollection.findById(request.params.id); + if (!doc) return h.response(error('NOT_FOUND', 'Collection not found')).code(404); + const user = request.auth.credentials.user; + if (!assertCollectionOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const p = request.payload; + const orgId = doc.organization; + + if (p.name != null) doc.name = String(p.name).trim(); + if (p.description != null) doc.description = String(p.description).trim(); + if (p.collectionType != null) doc.collectionType = p.collectionType; + if (p.coverIcon != null) doc.coverIcon = p.coverIcon; + if (Array.isArray(p.tags)) doc.tags = p.tags.map((t) => String(t).trim()).filter(Boolean); + if (p.visibility) doc.visibility = p.visibility; + + if (Array.isArray(p.contentIds)) { + const contentIds = p.contentIds + .map((id) => (mongoose.Types.ObjectId.isValid(id) ? new mongoose.Types.ObjectId(id) : null)) + .filter(Boolean); + if (contentIds.length) { + const n = await LibraryContent.countDocuments({ _id: { $in: contentIds }, organization: orgId }); + if (n !== contentIds.length) { + return h.response(error('VALIDATION_ERROR', 'Invalid library file reference')).code(400); + } + } + doc.contentIds = contentIds; + } + + if (Array.isArray(p.sharedWith)) { + doc.sharedWith = p.sharedWith + .map((id) => (mongoose.Types.ObjectId.isValid(id) ? new mongoose.Types.ObjectId(id) : null)) + .filter(Boolean); + } + + if (p.passwordProtected != null) doc.passwordProtected = Boolean(p.passwordProtected); + if (p.passwordProtected && p.password && String(p.password).trim()) { + doc.passwordHash = await bcrypt.hash(String(p.password).trim(), 10); + } + if (p.passwordProtected === false) doc.passwordHash = ''; + + if (p.expiryEnabled != null) doc.expiryEnabled = Boolean(p.expiryEnabled); + if (p.expiryDate != null) doc.expiryDate = p.expiryDate ? new Date(p.expiryDate) : undefined; + if (p.notifyOnAdd != null) doc.notifyOnAdd = Boolean(p.notifyOnAdd); + if (p.notifyOnShare != null) doc.notifyOnShare = Boolean(p.notifyOnShare); + + await doc.save(); + const lean = doc.toObject(); + delete lean.passwordHash; + return h.response(success(lean)); +}; + +const remove = async (request, h) => { + const doc = await ContentCollection.findById(request.params.id); + if (!doc) return h.response(error('NOT_FOUND', 'Collection not found')).code(404); + const user = request.auth.credentials.user; + if (!assertCollectionOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + await doc.deleteOne(); + return h.response(success(null, 'Collection deleted')).code(200); +}; + +module.exports = { list, create, get, update, remove }; diff --git a/src/controllers/designTemplate.controller.js b/src/controllers/designTemplate.controller.js new file mode 100644 index 0000000..0be3196 --- /dev/null +++ b/src/controllers/designTemplate.controller.js @@ -0,0 +1,92 @@ +const DesignTemplate = require('../models/DesignTemplate'); +const Event = require('../models/Event'); +const { success, error } = require('../utils/response'); +const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); + +const list = async (request, h) => { + const user = request.auth.credentials.user; + const scoped = resolveScopedOrganizationId(user, request.query.organization); + if (scoped.error) return h.response(error(scoped.error.code, scoped.error.message)).code(400); + const filter = { organization: scoped.organizationId }; + if (request.query.type) filter.type = request.query.type; + const { page = 1, limit = 20 } = request.query; + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); + const [templates, total] = await Promise.all([ + DesignTemplate.find(filter).skip(skip).limit(parseInt(limit, 10)).lean(), + DesignTemplate.countDocuments(filter), + ]); + return h.response(success({ templates, total, page: parseInt(page, 10), limit: parseInt(limit, 10) })) + .header('X-Total-Count', total); +}; + +const create = async (request, h) => { + const user = request.auth.credentials.user; + if (user.role !== 'super_admin') return h.response(error('FORBIDDEN', 'Only super_admin can create design templates')).code(403); + const orgId = request.payload.organization || (user.organization?._id || user.organization); + if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + const template = await DesignTemplate.create({ + ...request.payload, + organization: orgId, + createdBy: user._id, + }); + return h.response(success(template, 'Template created', 201)).code(201); +}; + +const get = async (request, h) => { + const template = await DesignTemplate.findById(request.params.id).populate('organization createdBy'); + if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && template.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(template)); +}; + +const update = async (request, h) => { + const user = request.auth.credentials.user; + if (user.role !== 'super_admin') return h.response(error('FORBIDDEN', 'Only super_admin can update design templates')).code(403); + const template = await DesignTemplate.findById(request.params.id); + if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); + Object.assign(template, request.payload); + await template.save(); + return h.response(success(template)); +}; + +const remove = async (request, h) => { + const user = request.auth.credentials.user; + if (user.role !== 'super_admin') return h.response(error('FORBIDDEN', 'Only super_admin can delete design templates')).code(403); + const template = await DesignTemplate.findById(request.params.id); + if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); + await template.deleteOne(); + return h.response(success(null, 'Template deleted')).code(200); +}; + +const duplicate = async (request, h) => { + const user = request.auth.credentials.user; + if (user.role !== 'super_admin') return h.response(error('FORBIDDEN', 'Only super_admin can duplicate design templates')).code(403); + const template = await DesignTemplate.findById(request.params.id); + if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); + const doc = template.toObject(); + delete doc._id; + delete doc.createdAt; + delete doc.updatedAt; + doc.name = `${doc.name} (Copy)`; + const created = await DesignTemplate.create(doc); + return h.response(success(created, 'Template duplicated', 201)).code(201); +}; + +const useForEvent = async (request, h) => { + const { id, eventId } = request.params; + const template = await DesignTemplate.findById(id); + if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!template.usedBy) template.usedBy = []; + if (!template.usedBy.some(e => e.toString() === eventId)) { + template.usedBy.push(event._id); + await template.save(); + } + return h.response(success({ template, event }, 'Template applied to event')); +}; + +module.exports = { list, create, get, update, remove, duplicate, useForEvent }; diff --git a/src/controllers/event.controller.js b/src/controllers/event.controller.js new file mode 100644 index 0000000..0cf46f2 --- /dev/null +++ b/src/controllers/event.controller.js @@ -0,0 +1,358 @@ +const Event = require('../models/Event'); +const User = require('../models/User'); +const OrganizationUserMap = require('../models/OrganizationUserMap'); +const { success, error } = require('../utils/response'); +const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); +const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media'); +const { registrationUrlForForm } = require('../utils/micrositeBuild'); + +function parseDataUrl(dataUrl) { + if (typeof dataUrl !== 'string') return null; + const trimmed = dataUrl.trim(); + const m = /^data:([^;]+);base64,(.+)$/.exec(trimmed); + if (!m) return null; + const mimeType = m[1]; + const base64 = m[2]; + return { + mimeType, + buffer: Buffer.from(base64, 'base64'), + }; +} + +function extensionFromMimeType(mimeType) { + const mt = (mimeType || '').toLowerCase(); + if (mt.includes('png')) return 'png'; + if (mt.includes('jpeg') || mt.includes('jpg')) return 'jpg'; + if (mt.includes('webp')) return 'webp'; + if (mt.includes('gif')) return 'gif'; + if (mt.includes('svg')) return 'svg'; + return 'bin'; +} + +async function maybeUploadImageValueToCloudFront({ + value, + orgId, + eventId, + module, + filenamePrefix, +}) { + if (value == null) return value; + if (typeof value !== 'string') return value; + + const trimmed = value.trim(); + if (!trimmed) return trimmed; + + // Already a URL. + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed; + + // Only upload base64 data URLs. + const parsed = parseDataUrl(trimmed); + if (!parsed) return trimmed; + + const filename = `${filenamePrefix}-${Date.now()}.${extensionFromMimeType(parsed.mimeType)}`; + + const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({ + buffer: parsed.buffer, + contentType: parsed.mimeType, + orgId, + eventId, + module, + filename, + }); + + return uploaded.url; +} + +/** Event-specific label in Permissions UI (see frontend ROLES). */ +const CREATOR_EVENT_ROLE = 'Co-organiser'; + +/** + * Put the creating user on the event staff list (OrganizationUserMap.eventAccess). + * No-op if the user does not belong to the same organization as the event (e.g. some super_admin cases). + */ +async function assignCreatorToEventStaff(creatorUserId, orgId, eventId) { + if (!creatorUserId || !orgId || !eventId) return; + const uid = creatorUserId._id || creatorUserId; + const target = await User.findById(uid).select('organization role').lean(); + if (!target?.organization || String(target.organization) !== String(orgId)) return; + + let map = await OrganizationUserMap.findOne({ user: uid, organization: orgId }); + if (!map) { + map = await OrganizationUserMap.create({ + user: uid, + organization: orgId, + role: target.role || 'viewer', + eventAccess: [], + }); + } + const eid = String(eventId); + if (map.eventAccess.some((e) => String(e.event) === eid)) return; + + map.eventAccess.push({ event: eventId, sessionIds: [], eventRole: CREATOR_EVENT_ROLE }); + await map.save(); +} + +const list = async (request, h) => { + const { organization: organizationQuery, status, type, page = 1, limit = 20 } = request.query; + const user = request.auth.credentials.user; + const scoped = resolveScopedOrganizationId(user, organizationQuery); + if (scoped.error) { + return h.response(error(scoped.error.code, scoped.error.message)).code(400); + } + const filter = { organization: scoped.organizationId }; + if (status) filter.status = status; + if (type) filter.type = type; + + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); + const [events, total] = await Promise.all([ + Event.find(filter).populate('organization').sort({ startDate: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(), + Event.countDocuments(filter), + ]); + return h.response(success({ events, total, page: parseInt(page, 10), limit: parseInt(limit, 10) })) + .header('X-Total-Count', total); +}; + +const create = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = request.payload.organization || (user.organization?._id || user.organization); + if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + if (user.role !== 'super_admin' && (user.organization?._id?.toString() !== orgId?.toString())) { + return h.response(error('FORBIDDEN', 'Cannot create event for this organization')).code(403); + } + const { metadata: metaIn, ...rest } = request.payload; + const event = await Event.create({ + ...rest, + organization: orgId, + metadata: { + totalRevenue: 0, + rating: 0, + ratingCount: 0, + ...(metaIn && typeof metaIn === 'object' ? metaIn : {}), + }, + }); + + // Convert base64 images to CloudFront URLs. + const eventId = event._id?.toString?.() || event._id; + let didUpdate = false; + if (rest.image != null) { + const next = await maybeUploadImageValueToCloudFront({ + value: rest.image, + orgId, + eventId, + module: 'event/logo', + filenamePrefix: 'event-logo', + }); + if (next !== rest.image) { + event.image = next; + didUpdate = true; + } + } + if (rest.bannerImage != null) { + const next = await maybeUploadImageValueToCloudFront({ + value: rest.bannerImage, + orgId, + eventId, + module: 'event/banner', + filenamePrefix: 'event-banner', + }); + if (next !== rest.bannerImage) { + event.bannerImage = next; + didUpdate = true; + } + } + + if (didUpdate) await event.save(); + + try { + await assignCreatorToEventStaff(user._id || user.id, orgId, event._id); + } catch (err) { + console.error('assignCreatorToEventStaff', err); + } + + return h.response(success(event, 'Event created', 201)).code(201); +}; + +const get = async (request, h) => { + const event = await Event.findById(request.params.id).populate('organization'); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(event)); +}; + +const update = async (request, h) => { + const event = await Event.findById(request.params.id); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const { metadata: metaIn, ...rest } = request.payload; + + const orgId = event.organization?._id || event.organization; + const eventId = event._id?.toString?.() || event._id; + if (rest.image != null) { + rest.image = await maybeUploadImageValueToCloudFront({ + value: rest.image, + orgId, + eventId, + module: 'event/logo', + filenamePrefix: 'event-logo', + }); + } + if (rest.bannerImage != null) { + rest.bannerImage = await maybeUploadImageValueToCloudFront({ + value: rest.bannerImage, + orgId, + eventId, + module: 'event/banner', + filenamePrefix: 'event-banner', + }); + } + + Object.assign(event, rest); + if (metaIn && typeof metaIn === 'object') { + const prev = event.metadata?.toObject?.() || event.metadata || {}; + event.metadata = { ...prev, ...metaIn }; + event.markModified('metadata'); + } + await event.save(); + return h.response(success(event)); +}; + +const remove = async (request, h) => { + const event = await Event.findById(request.params.id); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + await event.deleteOne(); + return h.response(success(null, 'Event deleted')).code(200); +}; + +const analytics = async (request, h) => { + const event = await Event.findById(request.params.id); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const Participant = require('../models/Participant'); + const Session = require('../models/Session'); + const QuizPoll = require('../models/QuizPoll'); + const RewardPoint = require('../models/RewardPoint'); + + const orgId = event.organization; + const eid = event._id; + const participantBase = [ + { $match: { tenantOrganization: orgId } }, + { $unwind: '$eventMemberships' }, + { $match: { 'eventMemberships.event': eid } }, + ]; + + const [ + statusAgg, + participantCountArr, + sessionCount, + upcomingSessionCount, + quizPollCount, + rewardByType, + ] = await Promise.all([ + Participant.aggregate([...participantBase, { $group: { _id: '$eventMemberships.status', count: { $sum: 1 } } }]), + Participant.aggregate([...participantBase, { $count: 'total' }]), + Session.countDocuments({ event: eid }), + Session.countDocuments({ + event: eid, + startTime: { $gt: new Date() }, + status: { $ne: 'cancelled' }, + }), + QuizPoll.countDocuments({ event: eid }), + RewardPoint.aggregate([ + { $match: { event: eid } }, + { $group: { _id: '$type', total: { $sum: '$points' } } }, + ]), + ]); + + const statusCounts = statusAgg.reduce((acc, c) => { + acc[c._id] = c.count; + return acc; + }, {}); + const participantCount = participantCountArr[0]?.total ?? 0; + const rewardMap = rewardByType.reduce((acc, r) => { + acc[r._id] = r.total; + return acc; + }, {}); + const pointsAwarded = Math.round(rewardMap.earned || 0); + const pointsRedeemed = Math.round(rewardMap.redeemed || 0); + + const meta = + event.metadata && typeof event.metadata.toObject === 'function' + ? event.metadata.toObject() + : event.metadata || {}; + + const analyticsData = { + ...meta, + statusCounts, + participantCount, + sessionCount, + upcomingSessionCount, + quizPollCount, + pointsAwarded, + pointsRedeemed, + }; + return h.response(success(analyticsData)); +}; + +const duplicate = async (request, h) => { + const event = await Event.findById(request.params.id); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const doc = event.toObject(); + delete doc._id; + delete doc.createdAt; + delete doc.updatedAt; + doc.name = `${doc.name} (Copy)`; + doc.status = 'draft'; + const created = await Event.create(doc); + try { + await assignCreatorToEventStaff(user._id || user.id, created.organization, created._id); + } catch (err) { + console.error('assignCreatorToEventStaff (duplicate)', err); + } + return h.response(success(created, 'Event duplicated', 201)).code(201); +}; + +/** Authenticated: resolve microsite registration URL for a chosen form (visual builder). */ +const registrationLink = async (request, h) => { + const event = await Event.findById(request.params.id).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const formId = request.query.formId; + if (!formId || !String(formId).trim()) { + return h.response(error('VALIDATION_ERROR', 'formId query parameter is required')).code(400); + } + const url = registrationUrlForForm(event, event.website, formId); + if (!url) { + return h + .response( + error( + 'VALIDATION_ERROR', + 'Could not build registration URL. Set MICROSITE_REGISTRATION_URL_TEMPLATE on the server, or set a fallback registration URL on the event website.', + ), + ) + .code(400); + } + return h.response(success({ url })); +}; + +module.exports = { list, create, get, update, remove, analytics, duplicate, registrationLink }; diff --git a/src/controllers/eventMicrosite.controller.js b/src/controllers/eventMicrosite.controller.js new file mode 100644 index 0000000..d45fd4d --- /dev/null +++ b/src/controllers/eventMicrosite.controller.js @@ -0,0 +1,328 @@ +const mongoose = require('mongoose'); +const Event = require('../models/Event'); +const EventMicrosite = require('../models/EventMicrosite'); +const EventPage = require('../models/EventPage'); +const { success, error } = require('../utils/response'); +const { WEBSITE_SECTION_TYPES } = require('../config/constants'); +const { + buildMicrositeDocumentHtml, + getConferenceCanvasStylePayload, + templateToWebsiteBag, +} = require('../utils/micrositeBuild'); +const { normalizePublishFolder, publishFolderAvailability } = require('../utils/publishFolderAvailability'); +const { runPublishToDisk } = require('../utils/runMicrositePublish'); + +function orgMismatch(event, user) { + if (user.role === 'super_admin') return false; + return event.organization?.toString() !== (user.organization?._id || user.organization)?.toString(); +} + +async function loadEventForUser(eventId, user) { + const event = await Event.findById(eventId); + if (!event) return { event: null, forbidden: false }; + if (orgMismatch(event, user)) return { event: null, forbidden: true }; + return { event, forbidden: false }; +} + +async function loadMicrositeForEvent(micrositeId, eventId, user) { + const { event, forbidden } = await loadEventForUser(eventId, user); + if (!event) return { event: null, microsite: null, forbidden }; + if (!mongoose.Types.ObjectId.isValid(micrositeId)) { + return { event, microsite: null, forbidden: false }; + } + const microsite = await EventMicrosite.findOne({ _id: micrositeId, event: event._id }); + return { event, microsite, forbidden: false }; +} + +function userMayAccessOrganization(user, organizationId) { + if (user.role === 'super_admin') return true; + const oid = (user.organization?._id || user.organization)?.toString(); + return oid === organizationId; +} + +/** All `EventMicrosite` rows for events in an organization (portfolio / Design → Microsite list). */ +const listForOrganization = async (request, h) => { + const organizationId = request.query.organization; + if (!organizationId || !mongoose.Types.ObjectId.isValid(String(organizationId))) { + return h.response(error('VALIDATION_ERROR', 'Valid organization id is required')).code(400); + } + const oid = String(organizationId); + const user = request.auth.credentials.user; + if (!userMayAccessOrganization(user, oid)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const events = await Event.find({ organization: oid }).select('_id name').lean(); + const eventIds = events.map((e) => e._id); + const nameByEventId = Object.fromEntries(events.map((e) => [e._id.toString(), e.name])); + const rows = + eventIds.length === 0 + ? [] + : await EventMicrosite.find({ event: { $in: eventIds } }) + .sort({ event: 1, createdAt: 1 }) + .populate('sourcePageId', 'title slug type') + .lean(); + const enriched = rows.map((r) => ({ + ...r, + eventName: nameByEventId[r.event.toString()] || null, + })); + return h.response(success(enriched)); +}; + +const list = async (request, h) => { + const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user); + if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const rows = await EventMicrosite.find({ event: event._id }) + .sort({ createdAt: 1 }) + .populate('sourcePageId', 'title slug type') + .lean(); + return h.response(success(rows)); +}; + +const getById = async (request, h) => { + const { event, microsite, forbidden } = await loadMicrositeForEvent( + request.params.micrositeId, + request.params.eventId, + request.auth.credentials.user, + ); + if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404); + return h.response(success(microsite)); +}; + +const create = async (request, h) => { + const user = request.auth.credentials.user; + const { event, forbidden } = await loadEventForUser(request.params.eventId, user); + if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const payload = request.payload; + const folder = normalizePublishFolder(payload.publishFolder || ''); + if (!folder) { + return h.response(error('VALIDATION_ERROR', 'publishFolder (URL slug) is required')).code(400); + } + const { available, message } = await publishFolderAvailability(folder, event._id.toString(), null); + if (!available) { + return h.response(error('VALIDATION_ERROR', message || 'Folder not available')).code(400); + } + + let sourcePageId = payload.sourcePageId || undefined; + if (sourcePageId) { + const page = await EventPage.findOne({ _id: sourcePageId, event: event._id }).select('_id').lean(); + if (!page) { + return h.response(error('VALIDATION_ERROR', 'sourcePageId must belong to this event')).code(400); + } + } + + const doc = new EventMicrosite({ + event: event._id, + name: payload.name, + publishFolder: folder, + enabled: !!payload.enabled, + logo: payload.logo, + banners: Array.isArray(payload.banners) ? payload.banners : [], + favicon: payload.favicon, + title: payload.title, + description: payload.description, + keywords: Array.isArray(payload.keywords) ? payload.keywords : [], + templateId: payload.templateId, + layoutTemplate: 'conference', + micrositeSections: Array.isArray(payload.micrositeSections) + ? payload.micrositeSections.filter((t) => typeof t === 'string' && WEBSITE_SECTION_TYPES.includes(t)) + : [], + primaryColor: payload.primaryColor, + accentColor: payload.accentColor, + fontPair: payload.fontPair, + registrationUrl: payload.registrationUrl, + registrationFormId: payload.registrationFormId || undefined, + seoDescription: payload.seoDescription, + micrositeHtml: payload.micrositeHtml, + sourcePageId: sourcePageId || undefined, + createdBy: user._id || user.id, + }); + + try { + await doc.save(); + } catch (e) { + if (e && e.code === 11000) { + return h.response(error('VALIDATION_ERROR', 'This folder is already in use')).code(400); + } + throw e; + } + const lean = await EventMicrosite.findById(doc._id).populate('sourcePageId', 'title slug type').lean(); + return h.response(success(lean)).code(201); +}; + +const update = async (request, h) => { + const user = request.auth.credentials.user; + const { event, microsite, forbidden } = await loadMicrositeForEvent( + request.params.micrositeId, + request.params.eventId, + user, + ); + if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404); + + const payload = request.payload; + const ok = new Set(WEBSITE_SECTION_TYPES); + + if (payload.publishFolder !== undefined) { + const folder = normalizePublishFolder(payload.publishFolder || ''); + if (!folder) { + return h.response(error('VALIDATION_ERROR', 'publishFolder cannot be empty')).code(400); + } + const { available, message } = await publishFolderAvailability( + folder, + event._id.toString(), + microsite._id.toString(), + ); + if (!available) { + return h.response(error('VALIDATION_ERROR', message || 'Folder not available')).code(400); + } + microsite.publishFolder = folder; + } + if (payload.name !== undefined) microsite.name = payload.name; + if (payload.enabled !== undefined) microsite.enabled = !!payload.enabled; + if (payload.logo !== undefined) microsite.logo = payload.logo; + if (payload.banners !== undefined) microsite.banners = Array.isArray(payload.banners) ? payload.banners : []; + if (payload.favicon !== undefined) microsite.favicon = payload.favicon; + if (payload.title !== undefined) microsite.title = payload.title; + if (payload.description !== undefined) microsite.description = payload.description; + if (payload.keywords !== undefined) microsite.keywords = Array.isArray(payload.keywords) ? payload.keywords : []; + if (payload.templateId !== undefined) microsite.templateId = payload.templateId; + if (payload.layoutTemplate !== undefined) microsite.layoutTemplate = 'conference'; + if (payload.micrositeSections !== undefined) { + microsite.micrositeSections = Array.isArray(payload.micrositeSections) + ? payload.micrositeSections.filter((t) => typeof t === 'string' && ok.has(t)) + : []; + } + if (payload.primaryColor !== undefined) microsite.primaryColor = payload.primaryColor; + if (payload.accentColor !== undefined) microsite.accentColor = payload.accentColor; + if (payload.fontPair !== undefined) microsite.fontPair = payload.fontPair; + if (payload.registrationUrl !== undefined) microsite.registrationUrl = payload.registrationUrl; + if (payload.registrationFormId !== undefined) { + microsite.registrationFormId = + payload.registrationFormId && String(payload.registrationFormId).trim() + ? payload.registrationFormId + : undefined; + } + if (payload.seoDescription !== undefined) microsite.seoDescription = payload.seoDescription; + if (payload.micrositeHtml !== undefined) microsite.micrositeHtml = payload.micrositeHtml; + if (payload.sourcePageId !== undefined) { + if (!payload.sourcePageId) { + microsite.sourcePageId = undefined; + } else { + const page = await EventPage.findOne({ _id: payload.sourcePageId, event: event._id }).select('_id').lean(); + if (!page) { + return h.response(error('VALIDATION_ERROR', 'sourcePageId must belong to this event')).code(400); + } + microsite.sourcePageId = payload.sourcePageId; + } + } + + try { + await microsite.save(); + } catch (e) { + if (e && e.code === 11000) { + return h.response(error('VALIDATION_ERROR', 'This folder is already in use')).code(400); + } + throw e; + } + const lean = await EventMicrosite.findById(microsite._id).populate('sourcePageId', 'title slug type').lean(); + return h.response(success(lean)); +}; + +const remove = async (request, h) => { + const { event, microsite, forbidden } = await loadMicrositeForEvent( + request.params.micrositeId, + request.params.eventId, + request.auth.credentials.user, + ); + if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404); + await EventMicrosite.deleteOne({ _id: microsite._id }); + return h.response(success({ ok: true })); +}; + +const publish = async (request, h) => { + const { event, microsite, forbidden } = await loadMicrositeForEvent( + request.params.micrositeId, + request.params.eventId, + request.auth.credentials.user, + ); + if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404); + + try { + const lean = microsite.toObject(); + const result = await runPublishToDisk(event, lean, request.payload); + if (result.err) { + return h.response(error(result.err.code, result.err.message)).code(result.err.status); + } + return h.response(success(result.data)); + } catch (err) { + const msg = err && err.message ? err.message : 'Publish failed'; + return h.response(error('SERVER_ERROR', msg)).code(500); + } +}; + +const previewHtml = async (request, h) => { + const { event, microsite, forbidden } = await loadMicrositeForEvent( + request.params.micrositeId, + request.params.eventId, + request.auth.credentials.user, + ); + if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404); + try { + const lean = microsite.toObject(); + const website = templateToWebsiteBag(lean); + const regUrl = + typeof website.registrationUrl === 'string' ? website.registrationUrl.trim() : ''; + const [docResult, canvasStyles] = await Promise.all([ + buildMicrositeDocumentHtml(event, { + previewOnly: true, + applyVisualMainOverride: false, + registrationUrl: regUrl || undefined, + website, + sourcePageId: lean.sourcePageId, + }), + getConferenceCanvasStylePayload(website), + ]); + return h.response(success({ html: docResult.html, canvasStyles })); + } catch (err) { + const msg = err && err.message ? err.message : 'Preview failed'; + return h.response(error('SERVER_ERROR', msg)).code(400); + } +}; + +const checkFolder = async (request, h) => { + const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user); + if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const folder = normalizePublishFolder(request.query.folder || ''); + if (!folder) { + return h.response(success({ available: false, message: 'Folder is required' })); + } + let excludeId = request.query.micrositeId || null; + if (excludeId && !mongoose.Types.ObjectId.isValid(excludeId)) excludeId = null; + const { available, message } = await publishFolderAvailability(folder, event._id.toString(), excludeId); + return h.response(success({ available, message })); +}; + +module.exports = { + listForOrganization, + list, + getById, + create, + update, + remove, + publish, + previewHtml, + checkFolder, +}; diff --git a/src/controllers/eventStaff.controller.js b/src/controllers/eventStaff.controller.js new file mode 100644 index 0000000..3a6be7f --- /dev/null +++ b/src/controllers/eventStaff.controller.js @@ -0,0 +1,413 @@ +const crypto = require('crypto'); +const Event = require('../models/Event'); +const User = require('../models/User'); +const Organization = require('../models/Organization'); +const OrganizationUserMap = require('../models/OrganizationUserMap'); +const EventInvite = require('../models/EventInvite'); +const Session = require('../models/Session'); +const jwt = require('jsonwebtoken'); +const { success, error } = require('../utils/response'); +const { sendEventInviteEmail } = require('../utils/mail'); + +const JWT_SECRET = process.env.JWT_SECRET || 'default-secret'; +const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'default-refresh-secret'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m'; +const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; + +async function getEventOr404(eventId) { + return Event.findById(eventId).populate('organization'); +} + +function canManageStaff(user, event) { + if (!event) return false; + if (user.role === 'super_admin') return true; + const orgId = user.organization?._id || user.organization; + if (!orgId || event.organization._id?.toString() !== orgId.toString()) return false; + return ['admin', 'moderator'].includes(user.role); +} + +function staffListFromMaps(maps, eventId) { + const eid = eventId.toString(); + const members = []; + for (const m of maps) { + const u = m.user; + if (!u || !u._id) continue; + const ea = (m.eventAccess || []).find((x) => x.event && x.event.toString() === eid); + if (!ea) continue; + members.push({ + userId: u._id, + email: u.email, + firstName: u.firstName, + lastName: u.lastName, + role: u.role, + sessionIds: (ea.sessionIds || []).map((id) => id.toString()), + eventRole: ea.eventRole || 'Viewer', + }); + } + return members; +} + +const listStaff = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const event = await getEventOr404(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const orgId = event.organization._id || event.organization; + const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user', '-password').lean(); + const members = staffListFromMaps(maps, eventId); + + const invites = await EventInvite.find({ event: eventId, status: 'pending' }) + .select('email eventRole sessionIds createdAt expiresAt') + .lean(); + + return h.response( + success({ + members, + invites: invites.map((i) => ({ + id: i._id, + email: i.email, + eventRole: i.eventRole, + sessionIds: (i.sessionIds || []).map((id) => id.toString()), + expiresAt: i.expiresAt, + createdAt: i.createdAt, + })), + }) + ); +}; + +const listAvailableUsers = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const event = await getEventOr404(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const orgId = event.organization._id || event.organization; + const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user', '-password').lean(); + const assigned = new Set(staffListFromMaps(maps, eventId).map((m) => m.userId.toString())); + + const pendingEmails = new Set( + (await EventInvite.find({ event: eventId, status: 'pending' }).distinct('email')).map((e) => String(e).toLowerCase()) + ); + + const orgUsers = await User.find({ organization: orgId }).select('-password').lean(); + const available = orgUsers.filter((u) => !assigned.has(u._id.toString()) && !pendingEmails.has(String(u.email).toLowerCase())); + + return h.response(success({ users: available })); +}; + +const addStaff = async (request, h) => { + const { eventId } = request.params; + const { userIds, sessionIds = [], eventRole = 'Viewer' } = request.payload; + const user = request.auth.credentials.user; + const event = await getEventOr404(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const orgId = event.organization._id || event.organization; + const sid = Array.isArray(sessionIds) ? sessionIds.filter(Boolean) : []; + + for (const uid of userIds || []) { + const target = await User.findById(uid); + if (!target || target.organization?.toString() !== orgId.toString()) continue; + + let map = await OrganizationUserMap.findOne({ user: uid, organization: orgId }); + if (!map) { + map = await OrganizationUserMap.create({ + user: uid, + organization: orgId, + role: target.role || 'viewer', + eventAccess: [], + }); + } + const idx = map.eventAccess.findIndex((e) => e.event.toString() === eventId); + if (idx >= 0) { + map.eventAccess[idx].sessionIds = sid; + map.eventAccess[idx].eventRole = eventRole; + } else { + map.eventAccess.push({ event: eventId, sessionIds: sid, eventRole }); + } + await map.save(); + } + + return h.response(success(null, 'Users added to event')); +}; + +const updateStaff = async (request, h) => { + const { eventId, userId } = request.params; + const { sessionIds = [], eventRole } = request.payload; + const user = request.auth.credentials.user; + const event = await getEventOr404(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const orgId = event.organization._id || event.organization; + const map = await OrganizationUserMap.findOne({ user: userId, organization: orgId }); + if (!map) return h.response(error('NOT_FOUND', 'User not on this event')).code(404); + + const idx = map.eventAccess.findIndex((e) => e.event.toString() === eventId); + if (idx < 0) return h.response(error('NOT_FOUND', 'User not on this event')).code(404); + + map.eventAccess[idx].sessionIds = Array.isArray(sessionIds) ? sessionIds.filter(Boolean) : []; + if (eventRole != null) map.eventAccess[idx].eventRole = eventRole; + await map.save(); + + return h.response(success(null, 'Updated')); +}; + +const removeStaff = async (request, h) => { + const { eventId, userId } = request.params; + const user = request.auth.credentials.user; + const event = await getEventOr404(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const orgId = event.organization._id || event.organization; + const map = await OrganizationUserMap.findOne({ user: userId, organization: orgId }); + if (!map) return h.response(success(null)); + + map.eventAccess = map.eventAccess.filter((e) => e.event.toString() !== eventId); + await map.save(); + return h.response(success(null, 'Removed from event')); +}; + +const sendInvites = async (request, h) => { + const { eventId } = request.params; + const { invites: inviteList, sessionIds = [] } = request.payload; + const user = request.auth.credentials.user; + const event = await getEventOr404(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const orgId = event.organization._id || event.organization; + const org = await Organization.findById(orgId); + const orgName = org?.name || ''; + const sid = Array.isArray(sessionIds) ? sessionIds.filter(Boolean) : []; + const baseUrl = (process.env.FRONTEND_URL || process.env.CORS_ORIGIN || 'http://localhost:5173').split(',')[0].trim(); + + let sent = 0; + const errors = []; + + for (const row of inviteList || []) { + const email = String(row.email || '').trim().toLowerCase(); + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errors.push({ email, message: 'Invalid email' }); + continue; + } + + const existingUser = await User.findOne({ email }); + if (existingUser && existingUser.organization?.toString() === orgId.toString()) { + const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user').lean(); + if (staffListFromMaps(maps, eventId).some((m) => m.userId.toString() === existingUser._id.toString())) { + errors.push({ email, message: 'Already on this event' }); + continue; + } + } + + await EventInvite.updateMany({ event: eventId, email, status: 'pending' }, { status: 'cancelled' }); + + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); + await EventInvite.create({ + email, + event: eventId, + organization: orgId, + invitedBy: user._id, + token, + sessionIds: sid, + eventRole: row.eventRole || 'Viewer', + expiresAt, + }); + + const acceptUrl = `${baseUrl.replace(/\/$/, '')}/invite/event?token=${token}`; + const ok = await sendEventInviteEmail({ + to: email, + acceptUrl, + eventName: event.name, + orgName, + role: row.eventRole || 'Viewer', + }); + if (ok) sent += 1; + else errors.push({ email, message: 'Email could not be sent (check SMTP or logs)' }); + } + + return h.response(success({ sent, errors })); +}; + +const cancelInvite = async (request, h) => { + const { eventId, inviteId } = request.params; + const user = request.auth.credentials.user; + const event = await getEventOr404(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + await EventInvite.updateOne({ _id: inviteId, event: eventId }, { status: 'cancelled' }); + return h.response(success(null)); +}; + +const resendInvite = async (request, h) => { + const { eventId, inviteId } = request.params; + const user = request.auth.credentials.user; + const event = await getEventOr404(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const inv = await EventInvite.findOne({ _id: inviteId, event: eventId, status: 'pending' }); + if (!inv) return h.response(error('NOT_FOUND', 'Invite not found')).code(404); + + const org = await Organization.findById(event.organization); + const baseUrl = (process.env.FRONTEND_URL || process.env.CORS_ORIGIN || 'http://localhost:5173').split(',')[0].trim(); + const acceptUrl = `${baseUrl.replace(/\/$/, '')}/invite/event?token=${inv.token}`; + await sendEventInviteEmail({ + to: inv.email, + acceptUrl, + eventName: event.name, + orgName: org?.name, + role: inv.eventRole, + }); + return h.response(success(null, 'Invitation resent')); +}; + +const invitePreview = async (request, h) => { + const { token } = request.params; + const inv = await EventInvite.findOne({ token, status: 'pending' }).populate('event', 'name').populate('organization', 'name'); + if (!inv || inv.expiresAt < new Date()) { + return h.response(error('NOT_FOUND', 'Invalid or expired invitation')).code(404); + } + const existing = await User.findOne({ email: inv.email }); + return h.response( + success({ + email: inv.email, + eventName: inv.event?.name, + organizationName: inv.organization?.name || (await Organization.findById(inv.organization))?.name, + eventRole: inv.eventRole, + accountExists: !!existing, + sameOrganization: existing ? existing.organization?.toString() === inv.organization.toString() : false, + }) + ); +}; + +const inviteAccept = async (request, h) => { + const { token, password, firstName, lastName } = request.payload; + const inv = await EventInvite.findOne({ token, status: 'pending' }).populate('event'); + if (!inv || inv.expiresAt < new Date()) { + return h.response(error('NOT_FOUND', 'Invalid or expired invitation')).code(400); + } + + const orgId = inv.organization; + let user = await User.findOne({ email: inv.email }); + + if (user) { + if (user.organization?.toString() !== orgId.toString()) { + return h.response(error('FORBIDDEN', 'This email belongs to another organization. Use the email you were invited with.')).code(403); + } + if (!password || !(await user.comparePassword(password))) { + return h.response(error('UNAUTHORIZED', 'Invalid password')).code(401); + } + } else { + if (!password || String(password).length < 6) { + return h.response(error('VALIDATION_ERROR', 'Password must be at least 6 characters')).code(400); + } + user = await User.create({ + email: inv.email, + password, + firstName: firstName || '', + lastName: lastName || '', + organization: orgId, + role: 'viewer', + }); + await OrganizationUserMap.create({ + user: user._id, + organization: orgId, + role: 'viewer', + eventAccess: [], + }); + } + + let map = await OrganizationUserMap.findOne({ user: user._id, organization: orgId }); + if (!map) { + map = await OrganizationUserMap.create({ + user: user._id, + organization: orgId, + role: user.role, + eventAccess: [], + }); + } + + const eid = inv.event._id.toString(); + const idx = map.eventAccess.findIndex((e) => e.event.toString() === eid); + const entry = { + event: inv.event._id, + sessionIds: inv.sessionIds || [], + eventRole: inv.eventRole || 'Viewer', + }; + if (idx >= 0) map.eventAccess[idx] = entry; + else map.eventAccess.push(entry); + await map.save(); + + inv.status = 'accepted'; + await inv.save(); + + const populated = await User.findById(user._id).select('-password').populate('organization'); + const accessToken = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + const refreshToken = jwt.sign({ id: user._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); + + return h.response( + success({ + user: populated, + token: accessToken, + refreshToken, + expiresIn: JWT_EXPIRES_IN, + }, 'Welcome! You now have access to this event.') + ); +}; + +const agendaTree = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const Track = require('../models/Track'); + const tracks = await Track.find({ event: eventId }).sort({ sortOrder: 1, name: 1 }).lean(); + const sessions = await Session.find({ event: eventId }).sort({ startTime: 1 }).lean(); + + const byTrack = new Map(); + tracks.forEach((t) => byTrack.set(t._id.toString(), { trackId: t._id, name: t.name, sessions: [] })); + const unassigned = { trackId: null, name: 'Unassigned', sessions: [] }; + + sessions.forEach((s) => { + const tid = s.track ? s.track.toString() : null; + const row = { + sessionId: s._id, + title: s.title, + startTime: s.startTime, + }; + if (tid && byTrack.has(tid)) byTrack.get(tid).sessions.push(row); + else unassigned.sessions.push(row); + }); + + const trackList = [...byTrack.values()]; + if (unassigned.sessions.length) trackList.push(unassigned); + + return h.response(success({ tracks: trackList })); +}; + +module.exports = { + listStaff, + listAvailableUsers, + addStaff, + updateStaff, + removeStaff, + sendInvites, + cancelInvite, + resendInvite, + invitePreview, + inviteAccept, + agendaTree, +}; diff --git a/src/controllers/form.controller.js b/src/controllers/form.controller.js new file mode 100644 index 0000000..e6810a1 --- /dev/null +++ b/src/controllers/form.controller.js @@ -0,0 +1,323 @@ +const mongoose = require('mongoose'); +const Form = require('../models/Form'); +const FormSubmission = require('../models/FormSubmission'); +const Event = require('../models/Event'); +const Participant = require('../models/Participant'); +const ParticipantList = require('../models/ParticipantList'); +const { success, error } = require('../utils/response'); + +function normalizeSubmitData(raw) { + if (!raw) return {}; + if (raw instanceof Map) return Object.fromEntries(raw); + return { ...raw }; +} + +async function upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj) { + const fields = form.fields || []; + const orgOid = + tenantOrganization instanceof mongoose.Types.ObjectId + ? tenantOrganization + : new mongoose.Types.ObjectId(String(tenantOrganization)); + + function throwRegValidation(message, code) { + const err = new Error(message); + err.code = code; + throw err; + } + + const requireMappedValue = (mapsTo, emptyMessage, missingConfigMessage) => { + const fld = fields.find((f) => f.mapsTo === mapsTo); + if (!fld?.label) throwRegValidation(missingConfigMessage, 'FORM_FIELD_CONFIG'); + const raw = dataObj[fld.label.trim()]; + const val = raw != null ? String(raw).trim() : ''; + if (!val) throwRegValidation(emptyMessage, 'REGISTRATION_REQUIRED'); + }; + + const emailField = fields.find((f) => f.mapsTo === 'email'); + if (!emailField?.label) { + throwRegValidation('Form missing email field mapping', 'FORM_FIELD_CONFIG'); + } + const emailRaw = dataObj[emailField.label.trim()]; + const email = emailRaw != null ? String(emailRaw).trim().toLowerCase() : ''; + if (!email) { + throwRegValidation('Email is required', 'EMAIL_REQUIRED'); + } + + requireMappedValue('firstName', 'First name is required', 'Form missing first name field mapping'); + requireMappedValue('lastName', 'Last name is required', 'Form missing last name field mapping'); + + const updates = {}; + const customKv = {}; + const allowed = ['firstName', 'lastName', 'email', 'phone', 'organization', 'designation', 'notes', 'displayName']; + + for (const f of fields) { + if (!f.label) continue; + const key = f.label.trim(); + const val = dataObj[key]; + if (val === undefined || val === null || String(val).trim() === '') continue; + const map = f.mapsTo; + if (!map) continue; + if (map === 'custom') { + const ck = f.customMapsKey?.trim(); + if (ck) customKv[ck] = val; + continue; + } + if (!allowed.includes(map)) continue; + updates[map] = map === 'email' ? String(val).trim().toLowerCase() : String(val).trim(); + } + + let p = await Participant.findOne({ tenantOrganization: orgOid, email }); + if (!p) { + p = await Participant.create({ + tenantOrganization: orgOid, + email, + firstName: updates.firstName, + lastName: updates.lastName, + phone: updates.phone, + organization: updates.organization, + designation: updates.designation, + notes: updates.notes, + displayName: updates.displayName, + customFields: Object.keys(customKv).length ? new Map(Object.entries(customKv)) : undefined, + }); + } else { + if (updates.firstName) p.firstName = updates.firstName; + if (updates.lastName) p.lastName = updates.lastName; + if (updates.phone) p.phone = updates.phone; + if (updates.organization) p.organization = updates.organization; + if (updates.designation) p.designation = updates.designation; + if (updates.notes) p.notes = updates.notes; + if (updates.displayName) p.displayName = updates.displayName; + if (Object.keys(customKv).length) { + if (!p.customFields) p.customFields = new Map(); + for (const [k, v] of Object.entries(customKv)) p.customFields.set(k, v); + } + await p.save(); + } + + const tagList = (form.settings?.onSubmitAddTags || []) + .map((t) => String(t).trim()) + .filter(Boolean); + if (tagList.length) { + p.tags = [...new Set([...(p.tags || []).map(String), ...tagList])]; + await p.save(); + } + + const listId = form.settings?.onSubmitParticipantListId; + if (listId && mongoose.Types.ObjectId.isValid(String(listId))) { + const list = await ParticipantList.findById(listId).lean(); + if (list && list.type === 'fixed' && String(list.tenantOrganization) === String(orgOid)) { + await ParticipantList.updateOne({ _id: listId }, { $addToSet: { participantIds: p._id } }); + } + } + + return p._id; +} + +function userOrgId(user) { + return user.organization?._id || user.organization; +} + +async function formBelongsToUserOrg(formDoc, user) { + if (user.role === 'super_admin') return true; + const uo = userOrgId(user); + if (!uo) return false; + const uos = uo.toString(); + if (formDoc.tenantOrganization && formDoc.tenantOrganization.toString() === uos) return true; + if (!formDoc.event) return false; + const ev = await Event.findById(formDoc.event).select('organization').lean(); + return ev && ev.organization?.toString() === uos; +} + +/** List forms for an event (legacy). */ +const list = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== userOrgId(user)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const forms = await Form.find({ event: eventId }).sort({ createdAt: -1 }).lean(); + const formIds = forms.map((f) => f._id); + const counts = await FormSubmission.aggregate([ + { $match: { form: { $in: formIds } } }, + { $group: { _id: '$form', count: { $sum: 1 } } }, + ]); + const countByForm = Object.fromEntries(counts.map((c) => [c._id.toString(), c.count])); + const formsWithCount = forms.map((f) => ({ ...f, submissionCount: countByForm[f._id.toString()] ?? 0 })); + return h.response(success({ forms: formsWithCount })); +}; + +/** Portfolio: all forms for the signed-in user's organization. */ +const listByOrganization = async (request, h) => { + const user = request.auth.credentials.user; + let tenantOidRaw = + user.role === 'super_admin' && request.query.organization + ? request.query.organization + : userOrgId(user); + if (!tenantOidRaw) { + return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + } + if (!mongoose.Types.ObjectId.isValid(String(tenantOidRaw))) { + return h.response(error('VALIDATION_ERROR', 'Invalid organization')).code(400); + } + const oid = new mongoose.Types.ObjectId(String(tenantOidRaw)); + const forms = await Form.find({ tenantOrganization: oid }) + .sort({ createdAt: -1 }) + .lean(); + const formIds = forms.map((f) => f._id); + const counts = await FormSubmission.aggregate([ + { $match: { form: { $in: formIds } } }, + { $group: { _id: '$form', count: { $sum: 1 } } }, + ]); + const countByForm = Object.fromEntries(counts.map((c) => [c._id.toString(), c.count])); + const formsWithCount = forms.map((f) => ({ ...f, submissionCount: countByForm[f._id.toString()] ?? 0 })); + return h.response(success({ forms: formsWithCount })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== userOrgId(user)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const form = await Form.create({ + ...request.payload, + event: eventId, + tenantOrganization: event.organization, + }); + return h.response(success(form, 'Form created', 201)).code(201); +}; + +/** Create form at organization scope (optional event link). */ +const createByOrganization = async (request, h) => { + const user = request.auth.credentials.user; + const body = request.payload; + let tenantOidRaw = + user.role === 'super_admin' && body.tenantOrganization + ? body.tenantOrganization + : userOrgId(user); + if (!tenantOidRaw) { + return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + } + if (!mongoose.Types.ObjectId.isValid(String(tenantOidRaw))) { + return h.response(error('VALIDATION_ERROR', 'Invalid organization')).code(400); + } + const toid = new mongoose.Types.ObjectId(String(tenantOidRaw)); + + let eventRef; + if (body.event) { + if (!mongoose.Types.ObjectId.isValid(String(body.event))) { + return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400); + } + const ev = await Event.findById(body.event).lean(); + if (!ev) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (user.role !== 'super_admin' && ev.organization?.toString() !== toid.toString()) { + return h.response(error('FORBIDDEN', 'Event does not belong to your organization')).code(403); + } + eventRef = body.event; + } + + const { event: _e, tenantOrganization: _t, ...rest } = body; + const form = await Form.create({ + ...rest, + tenantOrganization: toid, + ...(eventRef && { event: eventRef }), + }); + return h.response(success(form, 'Form created', 201)).code(201); +}; + +const get = async (request, h) => { + const form = await Form.findById(request.params.id).populate('event'); + if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); + const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); + if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + return h.response(success(form)); +}; + +const update = async (request, h) => { + const form = await Form.findById(request.params.id); + if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); + const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); + if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + const payload = { ...request.payload }; + delete payload.tenantOrganization; + Object.assign(form, payload); + await form.save(); + return h.response(success(form)); +}; + +const remove = async (request, h) => { + const form = await Form.findById(request.params.id); + if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); + const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); + if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + await form.deleteOne(); + return h.response(success(null, 'Form deleted')).code(200); +}; + +const submit = async (request, h) => { + const { formId } = request.params; + const form = await Form.findById(formId).lean(); + if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); + if (!form.settings?.isActive) return h.response(error('VALIDATION_ERROR', 'Form is not accepting submissions')).code(400); + const user = request.auth?.credentials?.user; + + const tenantOrganization = form.tenantOrganization; + const eventRef = form.event; + if (!tenantOrganization) { + return h.response(error('VALIDATION_ERROR', 'Form is not configured for submissions (missing organization)')).code(400); + } + + const dataObj = normalizeSubmitData(request.payload.data); + let participantId = request.payload.participantId; + + if (form.type === 'registration') { + try { + participantId = await upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj); + } catch (err) { + if ( + err.code === 'EMAIL_REQUIRED' || + err.code === 'REGISTRATION_REQUIRED' || + err.code === 'FORM_FIELD_CONFIG' + ) { + return h.response(error('VALIDATION_ERROR', err.message || 'Validation failed')).code(400); + } + throw err; + } + } + + const submission = await FormSubmission.create({ + form: formId, + tenantOrganization, + event: eventRef || undefined, + user: user?._id, + participant: participantId, + data: dataObj, + }); + return h.response(success(submission, 'Submission received', 201)).code(201); +}; + +const listSubmissions = async (request, h) => { + const form = await Form.findById(request.params.formId); + if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); + const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); + if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + const submissions = await FormSubmission.find({ form: form._id }).populate('participant user').sort({ submittedAt: -1 }).lean(); + return h.response(success({ submissions })); +}; + +module.exports = { + list, + listByOrganization, + create, + createByOrganization, + get, + update, + remove, + submit, + listSubmissions, +}; diff --git a/src/controllers/media.controller.js b/src/controllers/media.controller.js new file mode 100644 index 0000000..30f7600 --- /dev/null +++ b/src/controllers/media.controller.js @@ -0,0 +1,250 @@ +const mongoose = require('mongoose'); +const Content = require('../models/Content'); +const LibraryContent = require('../models/LibraryContent'); +const ContentCollection = require('../models/ContentCollection'); + +const LIBRARY_MODULE = 'content-library'; +const { success, error } = require('../utils/response'); +const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); +const { uploadBufferToS3AndGetCloudFrontUrl, deleteS3ObjectByKey } = require('../utils/s3Media'); + +function parseTagsFromPayload(payload) { + const raw = payload.tags; + if (raw == null) return []; + if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean); + if (typeof raw === 'string') { + const s = raw.trim(); + if (!s) return []; + try { + const j = JSON.parse(s); + if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); + } catch { + /* comma-separated */ + } + return s.split(/[,;\n]+/).map((t) => t.trim()).filter(Boolean); + } + return []; +} + +function inferContentType(mimeType, payloadType) { + if (payloadType && ['image', 'pdf', 'video', 'document', 'other'].includes(payloadType)) return payloadType; + const m = String(mimeType || '').toLowerCase(); + if (m.startsWith('image/')) return 'image'; + if (m.startsWith('video/')) return 'video'; + if (m === 'application/pdf') return 'pdf'; + if (m.startsWith('text/') || m.includes('document') || m.includes('msword')) return 'document'; + return 'document'; +} + +function normalizeVisibility(v) { + const s = String(v || '').toLowerCase(); + if (s === 'public') return 'public'; + return 'private'; +} + +/** + * Hapi route payload.output only allows data|stream|file. With output:'data', binary file parts are often + * a raw Buffer on payload.file (see @hapi/subtext internals.part). Some clients may send { _data } or { payload }. + */ +function extractUploadFile(payload) { + const file = payload.file; + if (!file) return null; + + if (Buffer.isBuffer(file)) { + return { + buffer: file, + filename: (typeof payload.name === 'string' && payload.name.trim()) || 'file', + mimeType: 'application/octet-stream', + }; + } + + if (typeof file === 'object' && file.payload && Buffer.isBuffer(file.payload)) { + return { + buffer: file.payload, + filename: file.filename || (typeof payload.name === 'string' && payload.name) || 'file', + mimeType: file.headers?.['content-type'] || 'application/octet-stream', + }; + } + + if (typeof file === 'object' && file._data && Buffer.isBuffer(file._data)) { + return { + buffer: file._data, + filename: file.filename || (typeof payload.name === 'string' && payload.name) || 'file', + mimeType: file.headers?.['content-type'] || 'application/octet-stream', + }; + } + + return null; +} + +function listOrgFilter(user, query) { + const scoped = resolveScopedOrganizationId(user, query.organization); + if (scoped.error) return { error: scoped.error }; + return { filter: { organization: scoped.organizationId } }; +} + +/** Non-library uploads (events, speakers, etc.) — `Content` collection. */ +const list = async (request, h) => { + const user = request.auth.credentials.user; + const orgRes = listOrgFilter(user, request.query); + if (orgRes.error) return h.response(error(orgRes.error.code, orgRes.error.message)).code(400); + const filter = { ...orgRes.filter }; + if (request.query.type) filter.type = request.query.type; + if (request.query.collectionName) filter.collectionName = request.query.collectionName; + const { page = 1, limit = 20 } = request.query; + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); + const [media, total] = await Promise.all([ + Content.find(filter).skip(skip).limit(parseInt(limit, 10)).lean(), + Content.countDocuments(filter), + ]); + return h.response(success({ media, total, page: parseInt(page, 10), limit: parseInt(limit, 10) })) + .header('X-Total-Count', total); +}; + +/** Portfolio → Library only — `LibraryContent` collection. */ +const listLibrary = async (request, h) => { + const user = request.auth.credentials.user; + const orgRes = listOrgFilter(user, request.query); + if (orgRes.error) return h.response(error(orgRes.error.code, orgRes.error.message)).code(400); + const filter = { ...orgRes.filter }; + if (request.query.type) filter.type = request.query.type; + const { page = 1, limit = 20 } = request.query; + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); + const [media, total] = await Promise.all([ + LibraryContent.find(filter).sort({ createdAt: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(), + LibraryContent.countDocuments(filter), + ]); + return h.response(success({ media, total, page: parseInt(page, 10), limit: parseInt(limit, 10) })) + .header('X-Total-Count', total); +}; + +const upload = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + const payload = request.payload; + const extracted = extractUploadFile(payload); + + // Required by your storage scheme: eventbuzz////... + const eventId = payload.eventId || payload.event_id || payload.event || ''; + const module = payload.module || payload.collectionName || 'general'; + const isLibraryUpload = String(module) === LIBRARY_MODULE; + + let url = payload.url || ''; + let size = 0; + let mimeType = ''; + let s3Key = ''; + + if (extracted) { + const { buffer, filename: origName, mimeType: ct } = extracted; + const filename = `${Date.now()}-${(origName || 'file').replace(/[^a-zA-Z0-9.-]/g, '_')}`; + mimeType = ct || 'application/octet-stream'; + + const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({ + buffer, + contentType: mimeType, + orgId, + eventId, + module, + filename, + }); + + url = uploaded.url; + s3Key = uploaded.s3Key; + size = buffer.length; + } else if (!payload.url) { + return h + .response( + error( + 'VALIDATION_ERROR', + 'No file received. Send multipart/form-data with a field named "file" (binary). Do not use JSON body for uploads.', + ), + ) + .code(400); + } + + const collRaw = payload.contentCollectionId || payload.content_collection_id; + if ( + isLibraryUpload && + collRaw && + mongoose.Types.ObjectId.isValid(String(collRaw)) + ) { + const collId = new mongoose.Types.ObjectId(String(collRaw)); + const collOk = await ContentCollection.exists({ _id: collId, organization: orgId }); + if (!collOk) { + return h.response(error('VALIDATION_ERROR', 'Invalid content collection for this organization')).code(400); + } + } + + const type = inferContentType(mimeType, payload.type); + const tags = parseTagsFromPayload(payload); + const description = typeof payload.description === 'string' ? payload.description.trim() : ''; + const visibility = normalizeVisibility(payload.visibility); + + const baseDoc = { + organization: orgId, + name: payload.name || extracted?.filename || 'Untitled', + description, + visibility, + type, + url, + size, + mimeType, + tags, + collectionName: payload.collectionName, + s3Key, + uploadedBy: user._id, + }; + + const media = isLibraryUpload + ? await LibraryContent.create(baseDoc) + : await Content.create(baseDoc); + + if (collRaw && mongoose.Types.ObjectId.isValid(String(collRaw)) && isLibraryUpload) { + const collId = new mongoose.Types.ObjectId(String(collRaw)); + await ContentCollection.updateOne({ _id: collId, organization: orgId }, { $addToSet: { contentIds: media._id } }); + } + + return h.response(success(media, 'File uploaded', 201)).code(201); +}; + +const get = async (request, h) => { + let media = await Content.findById(request.params.id); + if (!media) media = await LibraryContent.findById(request.params.id); + if (!media) return h.response(error('NOT_FOUND', 'File not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && media.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(media)); +}; + +const remove = async (request, h) => { + let media = await Content.findById(request.params.id); + if (!media) media = await LibraryContent.findById(request.params.id); + if (!media) return h.response(error('NOT_FOUND', 'File not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && media.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + // Prefer S3 deletion if we have the key, otherwise fall back to legacy local /uploads deletion. + if (media.s3Key) { + await deleteS3ObjectByKey({ s3Key: media.s3Key }); + } else if (media.url && media.url.startsWith('/uploads/')) { + // Legacy fallback (optional): local file deletion if still present. + try { + const path = require('path'); + const fs = require('fs'); + const filepath = path.join(process.cwd(), media.url); + if (fs.existsSync(filepath)) fs.unlinkSync(filepath); + } catch {} + } + + const id = media._id; + await media.deleteOne(); + await ContentCollection.updateMany({ contentIds: id }, { $pull: { contentIds: id } }); + return h.response(success(null, 'File deleted')).code(200); +}; + +module.exports = { list, listLibrary, upload, get, remove }; diff --git a/src/controllers/notification.controller.js b/src/controllers/notification.controller.js new file mode 100644 index 0000000..05d8115 --- /dev/null +++ b/src/controllers/notification.controller.js @@ -0,0 +1,55 @@ +const Notification = require('../models/Notification'); +const { success, error } = require('../utils/response'); + +const list = async (request, h) => { + const user = request.auth.credentials.user; + const { page = 1, limit = 20, status } = request.query; + const filter = { user: user._id }; + if (status) filter.status = status; + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); + const [notifications, total] = await Promise.all([ + Notification.find(filter).sort({ createdAt: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(), + Notification.countDocuments(filter), + ]); + return h.response(success({ notifications, total, page: parseInt(page, 10), limit: parseInt(limit, 10) })) + .header('X-Total-Count', total); +}; + +const unread = async (request, h) => { + const user = request.auth.credentials.user; + const notifications = await Notification.find({ user: user._id, status: 'unread' }).sort({ createdAt: -1 }).lean(); + const count = notifications.length; + return h.response(success({ notifications, count })); +}; + +const markRead = async (request, h) => { + const notification = await Notification.findOne({ + _id: request.params.id, + user: request.auth.credentials.user._id, + }); + if (!notification) return h.response(error('NOT_FOUND', 'Notification not found')).code(404); + notification.status = 'read'; + notification.readAt = new Date(); + await notification.save(); + return h.response(success(notification)); +}; + +const markAllRead = async (request, h) => { + await Notification.updateMany( + { user: request.auth.credentials.user._id, status: 'unread' }, + { status: 'read', readAt: new Date() } + ); + return h.response(success(null, 'All marked as read')); +}; + +const remove = async (request, h) => { + const notification = await Notification.findOne({ + _id: request.params.id, + user: request.auth.credentials.user._id, + }); + if (!notification) return h.response(error('NOT_FOUND', 'Notification not found')).code(404); + await notification.deleteOne(); + return h.response(success(null, 'Notification deleted')).code(200); +}; + +module.exports = { list, unread, markRead, markAllRead, remove }; diff --git a/src/controllers/organization.controller.js b/src/controllers/organization.controller.js new file mode 100644 index 0000000..abc5c46 --- /dev/null +++ b/src/controllers/organization.controller.js @@ -0,0 +1,139 @@ +const Organization = require('../models/Organization'); +const User = require('../models/User'); +const OrganizationUserMap = require('../models/OrganizationUserMap'); +const { success, error } = require('../utils/response'); +const { ROLES } = require('../config/constants'); + +const getOrganization = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(success(null)); + const org = await Organization.findById(orgId); + if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + return h.response(success(org)); +}; + +const updateOrganization = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + if (user.role !== 'super_admin') { + return h.response(error('FORBIDDEN', 'Only super administrators can update organisation settings')).code(403); + } + const org = await Organization.findById(orgId); + if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + const p = request.payload; + if (p.name != null) org.name = p.name; + if (p.description != null) org.description = p.description; + if (p.email != null) org.email = p.email; + if (p.phone != null) org.phone = p.phone; + if (p.website != null) org.website = p.website; + if (p.address != null) org.address = p.address; + if (p.logo != null) org.logo = p.logo; + if (p.timezone != null) org.timezone = p.timezone; + if (p.dateFormat != null) org.dateFormat = p.dateFormat; + if (p.language != null) org.language = p.language; + if (p.subscription != null && typeof p.subscription === 'object') { + const sub = org.subscription || {}; + const incoming = p.subscription; + if (incoming.plan != null) sub.plan = incoming.plan; + if (incoming.status != null) sub.status = incoming.status; + if (incoming.expiresAt !== undefined) sub.expiresAt = incoming.expiresAt; + if (incoming.maxEvents !== undefined) sub.maxEvents = incoming.maxEvents; + if (incoming.maxParticipants !== undefined) sub.maxParticipants = incoming.maxParticipants; + if (Array.isArray(incoming.featureOverrides)) sub.featureOverrides = incoming.featureOverrides.map(String); + org.subscription = sub; + } + await org.save(); + return h.response(success(org)); +}; + +const listUsers = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(success({ users: [] })); + if (user.role !== 'super_admin' && user.role !== 'admin') { + return h.response(error('FORBIDDEN', 'Only admin can list users')).code(403); + } + const users = await User.find({ organization: orgId }).select('-password').populate('organization').lean(); + return h.response(success({ users })); +}; + +const addUser = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + if (user.role !== 'super_admin' && user.role !== 'admin') { + return h.response(error('FORBIDDEN', 'Only admin can add users')).code(403); + } + const existing = await User.findOne({ email: request.payload.email }); + if (existing) return h.response(error('VALIDATION_ERROR', 'Email already registered')).code(400); + const role = request.payload.role || 'viewer'; + const newUser = await User.create({ + ...request.payload, + organization: orgId, + role, + }); + const eventIds = request.payload.assignedEvents || []; + await OrganizationUserMap.create({ + user: newUser._id, + organization: orgId, + role, + eventAccess: eventIds.map((id) => ({ event: id })), + }); + const out = newUser.toObject(); + delete out.password; + return h.response(success(out, 'User added', 201)).code(201); +}; + +const updateUser = async (request, h) => { + const target = await User.findById(request.params.id); + if (!target) return h.response(error('NOT_FOUND', 'User not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && user.role !== 'admin') { + return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403); + } + if (user.role === 'admin' && (target.organization?.toString() !== (user.organization?._id || user.organization)?.toString())) { + return h.response(error('FORBIDDEN', 'Cannot edit user from another organization')).code(403); + } + if (user.role === 'admin' && request.payload.role && ['super_admin', 'admin'].includes(request.payload.role)) { + return h.response(error('FORBIDDEN', 'Cannot assign this role')).code(403); + } + const { password, assignedEvents, ...rest } = request.payload; + Object.assign(target, rest); + if (password) target.password = password; + await target.save(); + if (assignedEvents !== undefined) { + const map = await OrganizationUserMap.findOne({ user: target._id }); + if (map) { + map.eventAccess = Array.isArray(assignedEvents) ? assignedEvents.map((id) => ({ event: id })) : []; + await map.save(); + } + } + const out = target.toObject(); + delete out.password; + return h.response(success(out)); +}; + +const removeUser = async (request, h) => { + const target = await User.findById(request.params.id); + if (!target) return h.response(error('NOT_FOUND', 'User not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && user.role !== 'admin') { + return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403); + } + if (user._id.toString() === target._id.toString()) { + return h.response(error('VALIDATION_ERROR', 'Cannot remove yourself')).code(400); + } + await target.deleteOne(); + return h.response(success(null, 'User removed')).code(200); +}; + +module.exports = { + getOrganization, + updateOrganization, + listUsers, + addUser, + updateUser, + removeUser, +}; diff --git a/src/controllers/page.controller.js b/src/controllers/page.controller.js new file mode 100644 index 0000000..8666a97 --- /dev/null +++ b/src/controllers/page.controller.js @@ -0,0 +1,133 @@ +const EventPage = require('../models/EventPage'); +const Event = require('../models/Event'); +const Session = require('../models/Session'); +const Speaker = require('../models/Speaker'); +const { success, error } = require('../utils/response'); +const { buildPageHtml } = require('../utils/websiteHtml'); + +const list = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const pages = await EventPage.find({ event: eventId }).sort({ 'settings.menuOrder': 1 }).lean(); + return h.response(success({ pages })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const slug = request.payload.slug || request.payload.title?.toLowerCase().replace(/\s+/g, '-'); + const page = await EventPage.create({ + ...request.payload, + slug, + event: eventId, + createdBy: user._id, + }); + return h.response(success(page, 'Page created', 201)).code(201); +}; + +const get = async (request, h) => { + const page = await EventPage.findById(request.params.id).populate('event'); + if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404); + const event = page.event && typeof page.event === 'object' && page.event._id + ? page.event + : await Event.findById(page.event); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + const orgId = event.organization?.toString?.() || (event.organization && event.organization._id && event.organization._id.toString()); + if (user.role !== 'super_admin' && orgId !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(page)); +}; + +const update = async (request, h) => { + const page = await EventPage.findById(request.params.id); + if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404); + const event = await Event.findById(page.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + Object.assign(page, request.payload); + await page.save(); + return h.response(success(page)); +}; + +const remove = async (request, h) => { + const page = await EventPage.findById(request.params.id); + if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404); + const event = await Event.findById(page.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + await page.deleteOne(); + return h.response(success(null, 'Page deleted')).code(200); +}; + +const publish = async (request, h) => { + const page = await EventPage.findById(request.params.id); + if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404); + page.settings = page.settings || {}; + page.settings.isPublished = true; + await page.save(); + return h.response(success(page, 'Page published')); +}; + +const publicPage = async (request, h) => { + const { eventSlug, pageSlug } = request.params; + const event = await Event.findOne({ name: new RegExp(eventSlug, 'i') }); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const page = await EventPage.findOne({ event: event._id, slug: pageSlug, 'settings.isPublished': true }); + if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404); + page.views = (page.views || 0) + 1; + page.lastViewed = new Date(); + await page.save({ validateBeforeSave: false }); + return h.response(success(page)).code(200); +}; + +const exportPageHtml = async (request, h) => { + const page = await EventPage.findById(request.params.id); + if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404); + const event = await Event.findById(page.event); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const [sessions, speakerList] = await Promise.all([ + Session.find({ event: event._id }).populate('track').sort({ startTime: 1 }).lean(), + (async () => { + const eventSessions = await Session.find({ event: event._id }).select('speakers').lean(); + const speakerIds = [...new Set(eventSessions.flatMap((s) => (s.speakers || []).map((id) => id.toString())))]; + if (speakerIds.length === 0) return []; + return Speaker.find({ _id: { $in: speakerIds }, profileVisibility: 'public' }).lean(); + })(), + ]); + const agendaItems = sessions.map((s) => ({ + title: s.title, + time: s.startTime ? new Date(s.startTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : '', + track: s.track?.name || s.track?.title || '', + })); + const speakers = speakerList.map((s) => ({ + name: [s.firstName, s.lastName].filter(Boolean).join(' '), + designation: s.title || s.organizationName || '', + photoUrl: s.photo || '', + })); + const registrationUrl = typeof request.query.registrationUrl === 'string' ? request.query.registrationUrl : ''; + const layoutTemplate = (event.website && event.website.layoutTemplate) || 'conference'; + const html = buildPageHtml(page, event, { agendaItems, speakers, registrationUrl, layoutTemplate }); + return h.response(success({ html })); +}; + +module.exports = { list, create, get, update, remove, publish, publicPage, exportPageHtml }; diff --git a/src/controllers/participant.controller.js b/src/controllers/participant.controller.js new file mode 100644 index 0000000..6f5d7bb --- /dev/null +++ b/src/controllers/participant.controller.js @@ -0,0 +1,966 @@ +const mongoose = require('mongoose'); +const Participant = require('../models/Participant'); +const Event = require('../models/Event'); +const ParticipantList = require('../models/ParticipantList'); +const { success, error } = require('../utils/response'); +const { parseParticipantCsv, splitTags, escapeCsvField } = require('../utils/csvParticipant'); +const { getMembership, flattenParticipantForEvent, baseQueryForEvent } = require('../utils/participantMembership'); +const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); + +const EXPORT_HEADERS_FIXED = [ + 'firstName', + 'lastName', + 'email', + 'phone', + 'organization', + 'designation', + 'status', + 'tags', + 'notes', + 'registrationDate', + 'eventId', + 'eventName', +]; + +async function assertEventAccess(eventId, user) { + const event = await Event.findById(eventId); + if (!event) return { error: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) }; + if ( + user.role !== 'super_admin' && + event.organization?.toString() !== (user.organization?._id || user.organization)?.toString() + ) { + return { error: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) }; + } + return { event }; +} + +function assertParticipantOrgAccess(participant, user) { + if (user.role === 'super_admin') return true; + const oid = user.organization?._id || user.organization; + return participant.tenantOrganization?.toString() === oid?.toString(); +} + +async function resolveCheckInEventId(participant, queryEventId) { + if (queryEventId && mongoose.Types.ObjectId.isValid(queryEventId)) { + if (!getMembership(participant, queryEventId)) return { err: error('BAD_REQUEST', 'Participant is not registered for this event') }; + return { eventId: String(queryEventId) }; + } + const m = participant.eventMemberships || []; + if (m.length === 1) return { eventId: String(m[0].event) }; + return { err: error('BAD_REQUEST', 'event_id query is required when participant has multiple events') }; +} + +/** Tenant-only $match for portfolio participant lists (never unscoped). */ +function listAllTenantOnlyMatch(user, queryOrganization, payloadOrganization) { + const scoped = resolveScopedOrganizationId(user, queryOrganization ?? payloadOrganization); + if (scoped.error) return { error: scoped.error }; + return { tenantMatch: { tenantOrganization: scoped.organizationId } }; +} + +function listAllCommonStages(query, options = {}) { + const { skipSort = false } = options; + const { q, tag } = query; + const stages = []; + stages.push({ + $addFields: { + _allTags: { + $setUnion: [ + { $ifNull: ['$tags', []] }, + { + $reduce: { + input: { $ifNull: ['$eventMemberships', []] }, + initialValue: [], + in: { $setUnion: ['$$value', { $ifNull: ['$$this.tags', []] }] }, + }, + }, + ], + }, + _primary: { + $reduce: { + input: { $ifNull: ['$eventMemberships', []] }, + initialValue: null, + in: { + $cond: [ + { + $or: [ + { $eq: ['$$value', null] }, + { + $and: [ + { $ne: ['$$this.registrationDate', null] }, + { + $or: [ + { $eq: ['$$value.registrationDate', null] }, + { $gt: ['$$this.registrationDate', '$$value.registrationDate'] }, + ], + }, + ], + }, + ], + }, + '$$this', + '$$value', + ], + }, + }, + }, + }, + }); + + if (tag && String(tag).trim()) { + stages.push({ $match: { _allTags: String(tag).trim() } }); + } + + if (q && String(q).trim()) { + const search = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(search, 'i'); + stages.push({ + $match: { + $or: [ + { firstName: re }, + { lastName: re }, + { email: re }, + { phone: re }, + { organization: re }, + { designation: re }, + ], + }, + }); + } + + if (!skipSort) stages.push({ $sort: { updatedAt: -1 } }); + return stages; +} + +function listAllProjectStages() { + return [ + { + $lookup: { + from: 'events', + localField: '_primary.event', + foreignField: '_id', + as: '_evPrimary', + }, + }, + { + $addFields: { + _eventDoc: { $arrayElemAt: ['$_evPrimary', 0] }, + }, + }, + { + $project: { + _id: 1, + firstName: 1, + lastName: 1, + email: 1, + phone: 1, + organization: 1, + designation: 1, + notes: 1, + registrationDate: '$_primary.registrationDate', + status: '$_primary.status', + checkedIn: '$_primary.checkedIn', + checkedInAt: '$_primary.checkedInAt', + tags: '$_allTags', + customFields: 1, + event: { + $cond: [ + { $ne: ['$_eventDoc', null] }, + { _id: '$_eventDoc._id', name: '$_eventDoc.name' }, + null, + ], + }, + }, + }, + ]; +} + +const BULK_MAX = 500; + +const listDistinctTags = async (request, h) => { + const user = request.auth.credentials.user; + const r = listAllTenantOnlyMatch(user, request.query.organization); + if (r.error) return h.response(error(r.error.code, r.error.message)).code(400); + const { tenantMatch } = r; + + const rows = await Participant.aggregate([ + { $match: tenantMatch }, + { + $addFields: { + _allTags: { + $setUnion: [ + { $ifNull: ['$tags', []] }, + { + $reduce: { + input: { $ifNull: ['$eventMemberships', []] }, + initialValue: [], + in: { $setUnion: ['$$value', { $ifNull: ['$$this.tags', []] }] }, + }, + }, + ], + }, + }, + }, + { $unwind: { path: '$_allTags', preserveNullAndEmptyArrays: false } }, + { $group: { _id: '$_allTags' } }, + { $sort: { _id: 1 } }, + { $limit: 200 }, + ]); + const tags = rows.map((r) => r._id).filter((t) => t != null && String(t).trim() !== ''); + return h.response(success({ tags })); +}; + +const listMatchingIds = async (request, h) => { + const user = request.auth.credentials.user; + const r = listAllTenantOnlyMatch(user, request.query.organization); + if (r.error) return h.response(error(r.error.code, r.error.message)).code(400); + const { tenantMatch } = r; + + const { q, tag } = request.query; + const stages = [ + { $match: tenantMatch }, + ...listAllCommonStages({ q, tag }, { skipSort: true }), + { $project: { _id: 1 } }, + { $limit: BULK_MAX + 1 }, + ]; + const rows = await Participant.aggregate(stages); + const capped = rows.length > BULK_MAX; + const trimmed = capped ? rows.slice(0, BULK_MAX) : rows; + const ids = trimmed.map((r) => String(r._id)); + return h.response(success({ ids, total: ids.length, capped })); +}; + +function assertFixedListOrgAccess(list, user) { + if (user.role === 'super_admin') return true; + const oid = user.organization?._id || user.organization; + return list.tenantOrganization?.toString() === oid?.toString(); +} + +const bulkParticipants = async (request, h) => { + const user = request.auth.credentials.user; + const { + action, + participantIds: idsRaw, + selectAllMatching, + q, + tag: filterTag, + tags: tagPayload, + eventIds: evIdsRaw, + listId, + } = request.payload || {}; + + let participantIds = [...new Set((idsRaw || []).map((x) => String(x)))].filter((id) => mongoose.Types.ObjectId.isValid(id)); + + if (selectAllMatching) { + const r = listAllTenantOnlyMatch(user, request.query.organization, request.payload?.organization); + if (r.error) return h.response(error(r.error.code, r.error.message)).code(400); + const { tenantMatch } = r; + const stages = [ + { $match: tenantMatch }, + ...listAllCommonStages({ q, tag: filterTag }, { skipSort: true }), + { $project: { _id: 1 } }, + { $limit: BULK_MAX + 1 }, + ]; + const rows = await Participant.aggregate(stages); + if (rows.length > BULK_MAX) { + return h + .response(error('VALIDATION_ERROR', `Too many participants match (max ${BULK_MAX}). Narrow filters or select fewer rows.`)) + .code(400); + } + participantIds = rows.map((r) => String(r._id)); + } + + if (!participantIds.length) { + return h.response(error('VALIDATION_ERROR', 'No participants selected')).code(400); + } + if (participantIds.length > BULK_MAX) { + return h.response(error('VALIDATION_ERROR', `At most ${BULK_MAX} participants per action`)).code(400); + } + + const oids = participantIds.map((id) => new mongoose.Types.ObjectId(id)); + const participants = await Participant.find({ _id: { $in: oids } }); + if (participants.length !== participantIds.length) { + return h.response(error('BAD_REQUEST', 'Some participants were not found')).code(400); + } + for (const p of participants) { + if (!assertParticipantOrgAccess(p, user)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + } + + if (action === 'delete') { + await Participant.deleteMany({ _id: { $in: oids } }); + return h.response(success({ deleted: participants.length })); + } + + if (action === 'add_tags' || action === 'remove_tags') { + const tagList = (tagPayload || []).map((t) => String(t).trim()).filter(Boolean); + if (!tagList.length) return h.response(error('VALIDATION_ERROR', 'tags required')).code(400); + const removeSet = new Set(tagList); + for (const p of participants) { + if (action === 'add_tags') { + p.tags = [...new Set([...(p.tags || []), ...tagList])]; + } else { + p.tags = (p.tags || []).filter((t) => !removeSet.has(t)); + } + await p.save(); + } + return h.response(success({ updated: participants.length })); + } + + if (action === 'assign_events') { + const eventIds = [...new Set((evIdsRaw || []).filter((id) => id && mongoose.Types.ObjectId.isValid(String(id))))].map( + (id) => String(id) + ); + if (!eventIds.length) return h.response(error('VALIDATION_ERROR', 'eventIds required')).code(400); + for (const eid of eventIds) { + const r = await assertEventAccess(eid, user); + if (r.error) return r.error(h); + } + let membershipsAdded = 0; + for (const p of participants) { + for (const eid of eventIds) { + if (!getMembership(p, eid)) { + p.eventMemberships.push({ + event: eid, + status: 'pending', + registrationDate: new Date(), + }); + membershipsAdded += 1; + } + } + await p.save(); + } + return h.response(success({ updated: participants.length, membershipsAdded })); + } + + if (action === 'add_to_fixed_list') { + if (!listId || !mongoose.Types.ObjectId.isValid(String(listId))) { + return h.response(error('VALIDATION_ERROR', 'listId required')).code(400); + } + const list = await ParticipantList.findById(listId); + if (!list) return h.response(error('NOT_FOUND', 'List not found')).code(404); + if (list.type !== 'fixed') return h.response(error('BAD_REQUEST', 'Only fixed lists accept member ids')).code(400); + if (!assertFixedListOrgAccess(list, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const seen = new Set((list.participantIds || []).map((x) => String(x))); + for (const pid of participantIds) { + if (!seen.has(pid)) { + list.participantIds.push(new mongoose.Types.ObjectId(pid)); + seen.add(pid); + } + } + await list.save(); + return h.response(success({ updated: participants.length, listSize: list.participantIds.length })); + } + + return h.response(error('BAD_REQUEST', 'Unknown action')).code(400); +}; + +const listAll = async (request, h) => { + const user = request.auth.credentials.user; + const r = listAllTenantOnlyMatch(user, request.query.organization); + if (r.error) return h.response(error(r.error.code, r.error.message)).code(400); + const { tenantMatch } = r; + + const { page = 1, limit = 20, q, tag } = request.query; + const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100); + const pageNum = Math.max(parseInt(page, 10) || 1, 1); + const skip = (pageNum - 1) * limitNum; + + const preFacet = [{ $match: tenantMatch }, ...listAllCommonStages({ q, tag })]; + const facet = await Participant.aggregate([ + ...preFacet, + { + $facet: { + meta: [{ $count: 'total' }], + rows: [{ $skip: skip }, { $limit: limitNum }, ...listAllProjectStages()], + }, + }, + ]); + + const total = facet[0]?.meta[0]?.total ?? 0; + const participants = facet[0]?.rows ?? []; + return h + .response(success({ participants, total, page: pageNum, limit: limitNum })) + .header('X-Total-Count', String(total)); +}; + +const list = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if ( + user.role !== 'super_admin' && + event.organization?.toString() !== (user.organization?._id || user.organization)?.toString() + ) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const orgId = event.organization; + const { page = 1, limit = 20, status, q, designation } = request.query; + + const eid = new mongoose.Types.ObjectId(String(eventId)); + const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100); + const pageNum = Math.max(parseInt(page, 10) || 1, 1); + const skip = (pageNum - 1) * limitNum; + + const preStages = [{ $match: { tenantOrganization: orgId } }, { $unwind: '$eventMemberships' }, { $match: { 'eventMemberships.event': eid } }]; + if (status) preStages.push({ $match: { 'eventMemberships.status': status } }); + if (designation) preStages.push({ $match: { designation } }); + if (q && String(q).trim()) { + const search = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(search, 'i'); + preStages.push({ + $match: { + $or: [ + { firstName: re }, + { lastName: re }, + { email: re }, + { phone: re }, + { organization: re }, + { designation: re }, + ], + }, + }); + } + + const facet = await Participant.aggregate([ + ...preStages, + { + $facet: { + meta: [{ $count: 'total' }], + rows: [ + { $sort: { 'eventMemberships.registrationDate': -1 } }, + { $skip: skip }, + { $limit: limitNum }, + { + $project: { + _id: 1, + firstName: 1, + lastName: 1, + email: 1, + phone: 1, + organization: 1, + designation: 1, + notes: 1, + customFields: 1, + payment: 1, + emailVerified: 1, + event: { $literal: eventId }, + status: '$eventMemberships.status', + checkedIn: '$eventMemberships.checkedIn', + checkedInAt: '$eventMemberships.checkedInAt', + guestToken: '$eventMemberships.guestToken', + tags: { + $setUnion: [{ $ifNull: ['$tags', []] }, { $ifNull: ['$eventMemberships.tags', []] }], + }, + registrationDate: '$eventMemberships.registrationDate', + }, + }, + ], + }, + }, + ]); + + const total = facet[0]?.meta[0]?.total ?? 0; + const participants = facet[0]?.rows ?? []; + return h.response(success({ participants, total, page: pageNum, limit: limitNum })).header('X-Total-Count', String(total)); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if ( + user.role !== 'super_admin' && + event.organization?.toString() !== (user.organization?._id || user.organization)?.toString() + ) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const orgId = event.organization; + const payload = { ...request.payload }; + const tags = Array.isArray(payload.tags) ? payload.tags : []; + delete payload.tags; + + const existing = await Participant.findOne({ + tenantOrganization: orgId, + email: String(payload.email || '').trim().toLowerCase(), + }); + + if (existing) { + if (getMembership(existing, eventId)) { + return h.response(error('CONFLICT', 'Participant already exists for this event')).code(409); + } + existing.eventMemberships.push({ + event: eventId, + status: 'pending', + registrationDate: new Date(), + }); + Object.assign(existing, payload); + if (tags.length) { + existing.tags = [...new Set([...(existing.tags || []), ...tags])]; + } + await existing.save(); + const flat = flattenParticipantForEvent(existing.toObject(), eventId); + return h.response(success(flat, 'Participant added', 201)).code(201); + } + + const doc = await Participant.create({ + ...payload, + tenantOrganization: orgId, + tags, + eventMemberships: [ + { + event: eventId, + status: 'pending', + registrationDate: new Date(), + }, + ], + }); + const flat = flattenParticipantForEvent(doc.toObject(), eventId); + return h.response(success(flat, 'Participant added', 201)).code(201); +}; + +const get = async (request, h) => { + const participant = await Participant.findById(request.params.id).lean(); + if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + const user = request.auth.credentials.user; + if (!assertParticipantOrgAccess(participant, user)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const eventId = request.query.event_id; + if (eventId) { + const flat = flattenParticipantForEvent(participant, eventId); + if (!flat) return h.response(error('NOT_FOUND', 'No membership for this event')).code(404); + const ev = await Event.findById(eventId).select('name').lean(); + return h.response(success({ ...flat, event: ev })); + } + + return h.response(success(participant)); +}; + +const update = async (request, h) => { + const participant = await Participant.findById(request.params.id); + if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + const user = request.auth.credentials.user; + if (!assertParticipantOrgAccess(participant, user)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const eventId = request.query.event_id; + const payload = { ...request.payload }; + const tags = payload.tags; + delete payload.tags; + + const assignable = ['email', 'firstName', 'lastName', 'phone', 'organization', 'designation', 'notes']; + for (const key of assignable) { + if (Object.prototype.hasOwnProperty.call(payload, key) && payload[key] !== undefined) { + participant[key] = payload[key]; + } + } + + if (tags !== undefined) { + participant.tags = Array.isArray(tags) ? tags : []; + } + + await participant.save(); + const out = eventId ? flattenParticipantForEvent(participant.toObject(), eventId) : participant.toObject(); + return h.response(success(out)); +}; + +const remove = async (request, h) => { + const participant = await Participant.findById(request.params.id); + if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + const user = request.auth.credentials.user; + if (!assertParticipantOrgAccess(participant, user)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const eventId = request.query.event_id; + if (eventId) { + participant.eventMemberships = participant.eventMemberships.filter((m) => String(m.event) !== String(eventId)); + if (participant.eventMemberships.length === 0) { + await participant.deleteOne(); + } else { + await participant.save(); + } + } else { + await participant.deleteOne(); + } + return h.response(success(null, 'Participant removed')).code(200); +}; + +const checkIn = async (request, h) => { + const participant = await Participant.findById(request.params.id); + if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + const user = request.auth.credentials.user; + if (!assertParticipantOrgAccess(participant, user)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const { eventId, err } = await resolveCheckInEventId(participant, request.query.event_id); + if (err) return h.response(err).code(400); + + const m = getMembership(participant, eventId); + m.checkedIn = true; + m.checkedInAt = new Date(); + m.status = 'checked_in'; + participant.markModified('eventMemberships'); + await participant.save(); + + const flat = flattenParticipantForEvent(participant.toObject(), eventId); + return h.response(success(flat, 'Checked in')); +}; + +const exportCsv = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const raw = await Participant.find({ + tenantOrganization: r.event.organization, + ...baseQueryForEvent(eventId), + }) + .sort({ updatedAt: -1 }) + .lean(); + const ev = await Event.findById(eventId).select('name').lean(); + const eventName = ev?.name ?? ''; + + const headerLine = EXPORT_HEADERS_FIXED.join(','); + const dataLines = raw.map((p) => { + const flat = flattenParticipantForEvent(p, eventId); + if (!flat) return null; + return EXPORT_HEADERS_FIXED.map((key) => { + let v = flat[key]; + if (key === 'tags' && Array.isArray(v)) v = v.join('; '); + if (key === 'registrationDate' && v) v = new Date(v).toISOString(); + if (key === 'eventId') v = String(eventId); + if (key === 'eventName') v = eventName; + return escapeCsvField(v); + }).join(','); + }).filter(Boolean); + + const csv = `\uFEFF${[headerLine, ...dataLines].join('\n')}`; + return h + .response(csv) + .type('text/csv; charset=utf-8') + .header('Content-Disposition', 'attachment; filename="participants-export.csv"') + .code(200); +}; + +const importCsv = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + const orgId = r.event.organization; + + const { csv } = request.payload; + const { rows } = parseParticipantCsv(csv); + if (rows.length === 0) { + return h.response(error('VALIDATION_ERROR', 'No data rows found in CSV')).code(400); + } + + let created = 0; + let skippedDuplicate = 0; + const rowErrors = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const lineNum = i + 2; + const email = String(row.email || '').trim().toLowerCase(); + const firstName = String(row.firstName || row.firstname || '').trim(); + const lastName = String(row.lastName || row.lastname || '').trim(); + + if (!email) { + rowErrors.push({ line: lineNum, message: 'Email is required' }); + continue; + } + if (!firstName) { + rowErrors.push({ line: lineNum, message: 'First name is required' }); + continue; + } + if (!lastName) { + rowErrors.push({ line: lineNum, message: 'Last name is required' }); + continue; + } + + const phone = String(row.phone || '').trim() || undefined; + const organization = String(row.organization || '').trim() || undefined; + const designation = String(row.designation || '').trim() || undefined; + const notes = String(row.notes || '').trim() || undefined; + const tags = splitTags(row.tags); + + try { + let p = await Participant.findOne({ tenantOrganization: orgId, email }); + if (p) { + if (getMembership(p, eventId)) { + skippedDuplicate += 1; + continue; + } + p.eventMemberships.push({ + event: eventId, + status: 'pending', + registrationDate: new Date(), + }); + if (tags.length) { + p.tags = [...new Set([...(p.tags || []), ...tags])]; + } + await p.save(); + created += 1; + } else { + await Participant.create({ + tenantOrganization: orgId, + email, + firstName, + lastName, + phone, + organization, + designation, + notes, + tags: tags.length ? tags : [], + eventMemberships: [ + { + event: eventId, + status: 'pending', + registrationDate: new Date(), + }, + ], + }); + created += 1; + } + } catch (e) { + if (e && e.code === 11000) { + skippedDuplicate += 1; + } else { + rowErrors.push({ line: lineNum, message: e.message || 'Create failed' }); + } + } + } + + return h.response( + success({ + created, + skippedDuplicate, + failed: rowErrors.length, + errors: rowErrors.slice(0, 50), + }) + ); +}; + +const exportAllCsv = async (request, h) => { + const user = request.auth.credentials.user; + const scoped = resolveScopedOrganizationId(user, request.query.organization); + if (scoped.error) return h.response(error(scoped.error.code, scoped.error.message)).code(400); + const tenantMatch = { tenantOrganization: scoped.organizationId }; + const eventDocs = await Event.find({ organization: scoped.organizationId }).select('_id name').lean(); + const eventMap = Object.fromEntries(eventDocs.map((e) => [String(e._id), e.name ?? ''])); + + const { q, tag } = request.query; + + const stages = [{ $match: tenantMatch }, ...listAllCommonStages({ q, tag }), ...listAllProjectStages()]; + const rows = await Participant.aggregate(stages); + const headerLine = EXPORT_HEADERS_FIXED.join(','); + const dataLines = rows.map((flat) => + EXPORT_HEADERS_FIXED.map((key) => { + let v = flat[key]; + if (key === 'tags' && Array.isArray(v)) v = v.join('; '); + if (key === 'registrationDate' && v) v = new Date(v).toISOString(); + if (key === 'status' && (v === undefined || v === null)) v = ''; + if (key === 'eventId') v = flat.event?._id ? String(flat.event._id) : ''; + if (key === 'eventName') { + v = flat.event?.name ?? (flat.event?._id ? eventMap[String(flat.event._id)] ?? '' : ''); + } + return escapeCsvField(v); + }).join(',') + ); + + const csv = `\uFEFF${[headerLine, ...dataLines].join('\n')}`; + return h + .response(csv) + .type('text/csv; charset=utf-8') + .header('Content-Disposition', 'attachment; filename="all-participants-export.csv"') + .code(200); +}; + +const importGlobalCsv = async (request, h) => { + const user = request.auth.credentials.user; + const { + csv, + eventIds: eventIdsRaw, + createCustomList, + listName, + listDescription, + } = request.payload || {}; + + const orgRaw = user.organization?._id || user.organization; + if (!orgRaw || !mongoose.Types.ObjectId.isValid(String(orgRaw))) { + return h + .response(error('VALIDATION_ERROR', 'Your account must belong to an organization to import participants')) + .code(400); + } + const tenantOrganization = orgRaw instanceof mongoose.Types.ObjectId ? orgRaw : new mongoose.Types.ObjectId(String(orgRaw)); + + if (createCustomList && (!listName || !String(listName).trim())) { + return h.response(error('VALIDATION_ERROR', 'List name is required when creating a custom list')).code(400); + } + + const { rows } = parseParticipantCsv(csv || ''); + if (rows.length === 0) { + return h.response(error('VALIDATION_ERROR', 'No data rows found in CSV')).code(400); + } + + const eventIds = (Array.isArray(eventIdsRaw) ? eventIdsRaw : []) + .filter((id) => id && mongoose.Types.ObjectId.isValid(String(id))) + .map((id) => String(id)); + + for (const eid of eventIds) { + const r = await assertEventAccess(eid, user); + if (r.error) return r.error(h); + } + + let participantsCreated = 0; + let membershipsAdded = 0; + let profilesUpdated = 0; + let skippedDuplicate = 0; + const rowErrors = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const lineNum = i + 2; + const email = String(row.email || '').trim().toLowerCase(); + const firstName = String(row.firstName || row.firstname || '').trim(); + const lastName = String(row.lastName || row.lastname || '').trim(); + if (!email) { + rowErrors.push({ line: lineNum, message: 'Email is required' }); + continue; + } + if (!firstName || !lastName) { + rowErrors.push({ line: lineNum, message: 'First and last name required' }); + continue; + } + const phone = String(row.phone || '').trim() || undefined; + const organization = String(row.organization || '').trim() || undefined; + const designation = String(row.designation || '').trim() || undefined; + const notes = String(row.notes || '').trim() || undefined; + const tags = splitTags(row.tags); + + try { + let p = await Participant.findOne({ tenantOrganization, email }); + if (!p) { + await Participant.create({ + tenantOrganization, + email, + firstName, + lastName, + phone, + organization, + designation, + notes, + tags: tags.length ? tags : [], + eventMemberships: eventIds.map((eid) => ({ + event: eid, + status: 'pending', + registrationDate: new Date(), + })), + }); + participantsCreated += 1; + continue; + } + + Object.assign(p, { firstName, lastName, phone, organization, designation, notes }); + if (tags.length) { + p.tags = [...new Set([...(p.tags || []), ...tags])]; + } + let newMemberships = 0; + for (const eid of eventIds) { + if (getMembership(p, eid)) { + skippedDuplicate += 1; + continue; + } + p.eventMemberships.push({ + event: eid, + status: 'pending', + registrationDate: new Date(), + }); + newMemberships += 1; + } + await p.save(); + if (newMemberships > 0) { + membershipsAdded += newMemberships; + } else { + profilesUpdated += 1; + } + } catch (e) { + if (e && e.code === 11000) skippedDuplicate += 1; + else rowErrors.push({ line: lineNum, message: e.message || 'failed' }); + } + } + + let listId; + let listLinked = 0; + if (createCustomList) { + const orderedIds = []; + const seenId = new Set(); + for (const row of rows) { + const em = String(row.email || '').trim().toLowerCase(); + if (!em) continue; + const found = await Participant.findOne({ tenantOrganization, email: em }).select('_id').lean(); + if (found) { + const sid = String(found._id); + if (!seenId.has(sid)) { + seenId.add(sid); + orderedIds.push(found._id); + listLinked += 1; + } + } + } + const list = await ParticipantList.create({ + name: String(listName).trim(), + description: listDescription ? String(listDescription).trim() : '', + tenantOrganization, + createdBy: user._id, + visibility: 'private', + type: 'fixed', + autoUpdate: false, + participantIds: orderedIds, + }); + listId = String(list._id); + } + + return h.response( + success({ + participantsCreated, + membershipsAdded, + profilesUpdated, + skippedDuplicate, + failed: rowErrors.length, + errors: rowErrors.slice(0, 50), + listId, + listRows: createCustomList ? rows.length : undefined, + listLinked: createCustomList ? listLinked : undefined, + }) + ); +}; + +module.exports = { + listAll, + listDistinctTags, + listMatchingIds, + bulkParticipants, + list, + create, + get, + update, + remove, + checkIn, + exportCsv, + importCsv, + exportAllCsv, + importGlobalCsv, +}; diff --git a/src/controllers/participantList.controller.js b/src/controllers/participantList.controller.js new file mode 100644 index 0000000..ef28526 --- /dev/null +++ b/src/controllers/participantList.controller.js @@ -0,0 +1,285 @@ +const mongoose = require('mongoose'); +const Event = require('../models/Event'); +const ParticipantList = require('../models/ParticipantList'); +const Participant = require('../models/Participant'); +const { success, error } = require('../utils/response'); +const { flattenParticipantForEvent } = require('../utils/participantMembership'); +const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); + +function assertListOrg(list, user) { + if (user.role === 'super_admin') return true; + const oid = user.organization?._id || user.organization; + return list.tenantOrganization?.toString() === oid?.toString(); +} + +async function resolveAllowedEventIds(user, queryOrganization) { + const scoped = resolveScopedOrganizationId(user, queryOrganization); + if (scoped.error) return { error: scoped.error }; + const ids = (await Event.find({ organization: scoped.organizationId }).select('_id').lean()).map((e) => e._id); + return { allowedIds: ids }; +} + +function mapUiStatusToApi(val) { + const s = String(val || '').toLowerCase().replace(/ /g, '_'); + if (s === 'checked_in' || s === 'checkedin') return 'checked_in'; + if (s === 'confirmed') return 'confirmed'; + if (s === 'cancelled' || s === 'canceled') return 'cancelled'; + if (s === 'pending') return 'pending'; + return String(val || '').toLowerCase(); +} + +function rulesToQuery(rules, conjunction, allowedEventIds, listTenantOrg) { + if (!allowedEventIds.length) return { _id: { $in: [] } }; + const oidList = allowedEventIds.map((id) => + id instanceof mongoose.Types.ObjectId ? id : new mongoose.Types.ObjectId(String(id)) + ); + + const clauses = (Array.isArray(rules) ? rules : []) + .map((rule) => { + const field = String(rule.field || ''); + const op = String(rule.operator || 'is'); + const val = String(rule.value || '').trim(); + + if (field === 'Status') { + const st = mapUiStatusToApi(val); + return { + eventMemberships: { + $elemMatch: { + event: { $in: oidList }, + status: op === 'is not' ? { $ne: st } : st, + }, + }, + }; + } + if (field === 'Tag') { + const inAllowedEvents = { + eventMemberships: { $elemMatch: { event: { $in: oidList } } }, + }; + const legacyMembershipTag = { + eventMemberships: { + $elemMatch: { event: { $in: oidList }, tags: val }, + }, + }; + if (op === 'is not') { + return { + $and: [ + inAllowedEvents, + { tags: { $nin: [val] } }, + { + eventMemberships: { + $not: { + $elemMatch: { event: { $in: oidList }, tags: val }, + }, + }, + }, + ], + }; + } + return { + $and: [inAllowedEvents, { $or: [{ tags: val }, legacyMembershipTag] }], + }; + } + if (field === 'Event') { + if (!mongoose.Types.ObjectId.isValid(val)) return null; + const eid = new mongoose.Types.ObjectId(val); + if (!oidList.some((x) => String(x) === String(eid))) return null; + return { + eventMemberships: { + $elemMatch: { + event: op === 'is not' ? { $nin: [eid] } : eid, + }, + }, + }; + } + if (field === 'Role' || field === 'Registration Date') { + return null; + } + return null; + }) + .filter(Boolean); + + const base = { + tenantOrganization: + listTenantOrg instanceof mongoose.Types.ObjectId + ? listTenantOrg + : new mongoose.Types.ObjectId(String(listTenantOrg)), + }; + + if (!clauses.length) { + return { + ...base, + eventMemberships: { $elemMatch: { event: { $in: oidList } } }, + }; + } + + const combined = conjunction === 'OR' ? { $or: clauses } : { $and: clauses }; + return { $and: [base, combined] }; +} + +const list = async (request, h) => { + const user = request.auth.credentials.user; + const scoped = resolveScopedOrganizationId(user, request.query.organization); + if (scoped.error) return h.response(error(scoped.error.code, scoped.error.message)).code(400); + const filter = { tenantOrganization: scoped.organizationId }; + const lists = await ParticipantList.find(filter).sort({ updatedAt: -1 }).lean(); + return h.response(success({ lists })); +}; + +const create = async (request, h) => { + const user = request.auth.credentials.user; + const tenantOrganization = + user.role === 'super_admin' && request.payload.tenantOrganization + ? request.payload.tenantOrganization + : user.organization?._id || user.organization; + if (!tenantOrganization) { + return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + } + + const { name, description, visibility, type, autoUpdate, rules, participantIds: pidsRaw } = request.payload; + let participantIds = []; + if (type === 'fixed' && Array.isArray(pidsRaw) && pidsRaw.length) { + participantIds = pidsRaw + .filter((id) => id && mongoose.Types.ObjectId.isValid(String(id))) + .map((id) => new mongoose.Types.ObjectId(String(id))); + } + const doc = await ParticipantList.create({ + name, + description, + visibility, + type, + autoUpdate: autoUpdate !== false, + rules: type === 'rule_based' ? rules : null, + tenantOrganization, + createdBy: user._id, + participantIds, + }); + return h.response(success(doc, 'List created', 201)).code(201); +}; + +const get = async (request, h) => { + const user = request.auth.credentials.user; + const doc = await ParticipantList.findById(request.params.id).lean(); + if (!doc) return h.response(error('NOT_FOUND', 'List not found')).code(404); + if (!assertListOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + return h.response(success(doc)); +}; + +const update = async (request, h) => { + const user = request.auth.credentials.user; + const doc = await ParticipantList.findById(request.params.id); + if (!doc) return h.response(error('NOT_FOUND', 'List not found')).code(404); + if (!assertListOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const { name, description, visibility, type, autoUpdate, rules, participantIds } = request.payload; + if (name !== undefined) doc.name = name; + if (description !== undefined) doc.description = description; + if (visibility !== undefined) doc.visibility = visibility; + if (type !== undefined) doc.type = type; + if (autoUpdate !== undefined) doc.autoUpdate = autoUpdate; + if (rules !== undefined) doc.rules = rules; + if (participantIds !== undefined && Array.isArray(participantIds) && doc.type === 'fixed') { + doc.participantIds = participantIds + .filter((id) => id && mongoose.Types.ObjectId.isValid(String(id))) + .map((id) => new mongoose.Types.ObjectId(String(id))); + } + await doc.save(); + return h.response(success(doc.toObject())); +}; + +const remove = async (request, h) => { + const user = request.auth.credentials.user; + const doc = await ParticipantList.findById(request.params.id); + if (!doc) return h.response(error('NOT_FOUND', 'List not found')).code(404); + if (!assertListOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + await doc.deleteOne(); + return h.response(success(null, 'List deleted')); +}; + +const listMembers = async (request, h) => { + const user = request.auth.credentials.user; + const doc = await ParticipantList.findById(request.params.id).lean(); + if (!doc) return h.response(error('NOT_FOUND', 'List not found')).code(404); + if (!assertListOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + + const { page = 1, limit = 50 } = request.query; + const limitNum = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200); + const pageNum = Math.max(parseInt(page, 10) || 1, 1); + const skip = (pageNum - 1) * limitNum; + + if (doc.type === 'fixed') { + const idList = (doc.participantIds || []).map((id) => String(id)); + const total = idList.length; + const slice = idList.slice(skip, skip + limitNum); + const oids = slice.map((id) => new mongoose.Types.ObjectId(id)); + const raw = oids.length ? await Participant.find({ _id: { $in: oids } }).lean() : []; + const byId = new Map(raw.map((p) => [String(p._id), p])); + const ev = await resolveAllowedEventIds(user, request.query.organization); + if (ev.error) return h.response(error(ev.error.code, ev.error.message)).code(400); + const allowedIds = ev.allowedIds; + const members = slice.map((idStr) => { + const p = byId.get(idStr); + if (!p) { + return { + kind: 'participant', + participantId: idStr, + name: '—', + email: '', + organization: '', + eventId: undefined, + status: undefined, + tags: [], + }; + } + const m = (p.eventMemberships || []).find((em) => allowedIds.some((e) => String(e) === String(em.event))); + const ev = m ? String(m.event) : null; + const flat = ev ? flattenParticipantForEvent(p, ev) : { ...p, tags: p.tags || [], status: undefined }; + const name = [flat.firstName, flat.lastName].filter(Boolean).join(' ').trim() || '—'; + return { + kind: 'participant', + participantId: String(p._id), + name, + email: flat.email ?? '', + organization: flat.organization ?? '', + eventId: ev ?? undefined, + status: flat.status, + tags: flat.tags, + }; + }); + return h.response(success({ members, total, page: pageNum, limit: limitNum })); + } + + const ev = await resolveAllowedEventIds(user, request.query.organization); + if (ev.error) return h.response(error(ev.error.code, ev.error.message)).code(400); + const allowedIds = ev.allowedIds; + const rulesPayload = doc.rules && typeof doc.rules === 'object' ? doc.rules : {}; + const rules = rulesPayload.rules || []; + const conjunction = rulesPayload.conjunction || 'AND'; + + const match = rulesToQuery(rules, conjunction, allowedIds, doc.tenantOrganization); + + const [raw, total] = await Promise.all([ + Participant.find(match).sort({ updatedAt: -1 }).skip(skip).limit(limitNum).lean(), + Participant.countDocuments(match), + ]); + + const members = raw.map((p) => { + const m = (p.eventMemberships || []).find((em) => allowedIds.some((e) => String(e) === String(em.event))); + const ev = m ? String(m.event) : null; + const flat = ev ? flattenParticipantForEvent(p, ev) : { ...p }; + const name = [flat.firstName, flat.lastName].filter(Boolean).join(' ').trim() || '—'; + return { + kind: 'participant', + participantId: String(p._id), + name, + email: flat.email ?? '', + organization: flat.organization ?? '', + eventId: ev, + status: flat.status, + tags: flat.tags, + }; + }); + + return h.response(success({ members, total, page: pageNum, limit: limitNum })); +}; + +module.exports = { list, create, get, update, remove, listMembers }; diff --git a/src/controllers/payment.controller.js b/src/controllers/payment.controller.js new file mode 100644 index 0000000..d4fc95c --- /dev/null +++ b/src/controllers/payment.controller.js @@ -0,0 +1,356 @@ +const Event = require('../models/Event'); +const Participant = require('../models/Participant'); +const PaymentTransaction = require('../models/PaymentTransaction'); +const { success, error } = require('../utils/response'); +const { createPaymentOrderCore } = require('../services/payments/createOrderCore'); +const { verifyPaymentCore, applyPaidStatus } = require('../services/payments/verifyPaymentCore'); +const { resolveForPaymentTransaction } = require('../services/payments/resolver'); +const { razorpay: razorpayAdapter } = require('../services/payments/adapters'); + +const participantMembership = require('../utils/participantMembership'); + +const listTransactions = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return hResponseEmpty(h); + + const { q, eventId, status, from, to, page, limit } = request.query; + const filter = { organization: orgId }; + if (eventId) filter.event = eventId; + if (status) filter.status = status; + if (from || to) { + filter.createdAt = {}; + if (from) filter.createdAt.$gte = new Date(from); + if (to) filter.createdAt.$lte = new Date(to); + } + if (q && String(q).trim()) { + const rx = new RegExp(String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + filter.$or = [ + { externalOrderId: rx }, + { externalPaymentId: rx }, + ]; + } + + const p = Math.max(1, parseInt(page, 10) || 1); + const l = Math.min(100, Math.max(1, parseInt(limit, 10) || 20)); + const skip = (p - 1) * l; + + const [rows, total] = await Promise.all([ + PaymentTransaction.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(l) + .populate('event', 'name') + .populate('participant', 'email firstName lastName') + .lean(), + PaymentTransaction.countDocuments(filter), + ]); + + return h.response(success({ transactions: rows, total, page: p, limit: l })); +}; + +function hResponseEmpty(h) { + return h.response(success({ transactions: [], total: 0, page: 1, limit: 20 })); +} + +function csvEscape(s) { + const x = String(s ?? ''); + if (/[",\n]/.test(x)) return `"${x.replace(/"/g, '""')}"`; + return x; +} + +const exportTransactions = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) { + return h + .response('createdAt,status\n') + .type('text/csv') + .header('Content-Disposition', 'attachment; filename="payment-transactions.csv"'); + } + + const { q, eventId, status, from, to } = request.query; + const filter = { organization: orgId }; + if (eventId) filter.event = eventId; + if (status) filter.status = status; + if (from || to) { + filter.createdAt = {}; + if (from) filter.createdAt.$gte = new Date(from); + if (to) filter.createdAt.$lte = new Date(to); + } + if (q && String(q).trim()) { + const rx = new RegExp(String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + filter.$or = [{ externalOrderId: rx }, { externalPaymentId: rx }]; + } + + const txs = await PaymentTransaction.find(filter) + .sort({ createdAt: -1 }) + .limit(5000) + .populate('event', 'name') + .populate('participant', 'email firstName lastName') + .lean(); + + const headers = [ + 'createdAt', + 'status', + 'currency', + 'amountSubtotal', + 'commissionAmount', + 'amountGross', + 'amountNet', + 'gatewayProvider', + 'checkoutSource', + 'externalOrderId', + 'externalPaymentId', + 'eventName', + 'participantEmail', + ]; + const lines = [headers.join(',')]; + for (const tx of txs) { + const ev = tx.event && typeof tx.event === 'object' ? tx.event.name || '' : ''; + const em = + tx.participant && typeof tx.participant === 'object' ? tx.participant.email || '' : ''; + lines.push( + [ + tx.createdAt ? new Date(tx.createdAt).toISOString() : '', + tx.status, + tx.currency, + tx.amountSubtotal, + tx.commissionAmount, + tx.amountGross, + tx.amountNet, + tx.gatewayProvider, + tx.checkoutSource, + csvEscape(tx.externalOrderId), + csvEscape(tx.externalPaymentId), + csvEscape(ev), + csvEscape(em), + ].join(','), + ); + } + return h + .response(`${lines.join('\n')}\n`) + .type('text/csv; charset=utf-8') + .header('Content-Disposition', 'attachment; filename="payment-transactions.csv"'); +}; + +const createOrder = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + + const { amountSubtotal, currency, eventId, participantId, idempotencyKey, formId } = request.payload; + try { + const result = await createPaymentOrderCore({ + organizationId: orgId, + eventId: eventId || null, + participantId: participantId || null, + amountSubtotalMinor: amountSubtotal, + currency, + idempotencyKey, + registrationContext: formId ? { formId } : null, + }); + return h + .response( + success({ + keyId: result.keyId, + orderId: result.orderId, + amount: result.amount, + currency: result.currency, + transactionId: result.transaction._id.toString(), + checkoutSource: result.checkoutSource, + commissionAmount: result.commissionAmount, + amountSubtotal: result.amountSubtotal, + amountGross: result.amountGross, + amountNet: result.amountNet, + }), + ) + .code(201); + } catch (e) { + if (e.code === 'IDEMPOTENCY_COMPLETE') { + return h.response(error('VALIDATION_ERROR', e.message)).code(409); + } + if (e.code === 'GATEWAY_NOT_IMPLEMENTED' || e.code === 'PLATFORM_CHECKOUT_DISABLED' || e.code === 'PLATFORM_RAZORPAY_MISSING' || e.code === 'NO_GATEWAY' || e.code === 'IDEMPOTENCY_MISMATCH' || e.code === 'INVALID_AMOUNT' || e.code === 'RAZORPAY_SDK_MISSING') { + return h.response(error('VALIDATION_ERROR', e.message)).code(e.code === 'GATEWAY_NOT_IMPLEMENTED' ? 501 : 400); + } + throw e; + } +}; + +const verify = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + + const { orderId, paymentId, signature } = request.payload; + const out = await verifyPaymentCore({ + orderId, + paymentId, + signature, + organizationId: orgId, + }); + if (out.error) { + const code = out.error === 'NOT_FOUND' ? 404 : 400; + return h.response(error(out.error, out.message)).code(code); + } + return h.response(success({ verified: true, transaction: out.transaction })); +}; + +const publicCreateOrder = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId).select('organization'); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const orgId = event.organization; + const { + amountSubtotal, + currency, + participantId, + guestToken, + email, + idempotencyKey, + } = request.payload; + + let participant = null; + if (participantId) { + participant = await Participant.findOne({ + _id: participantId, + tenantOrganization: orgId, + }); + } else if (guestToken && email) { + const q = participantMembership.membershipGuestQuery(orgId, eventId, guestToken); + participant = await Participant.findOne(q); + const em = String(email).trim().toLowerCase(); + if (participant && participant.email && participant.email.toLowerCase() !== em) { + participant = null; + } + } + + if (!participant || !participantMembership.participantInEvent(participant, eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid participant for this event')).code(400); + } + + try { + const result = await createPaymentOrderCore({ + organizationId: orgId, + eventId, + participantId: participant._id, + amountSubtotalMinor: amountSubtotal, + currency, + idempotencyKey, + }); + return h + .response( + success({ + keyId: result.keyId, + orderId: result.orderId, + amount: result.amount, + currency: result.currency, + transactionId: result.transaction._id.toString(), + checkoutSource: result.checkoutSource, + commissionAmount: result.commissionAmount, + amountSubtotal: result.amountSubtotal, + amountGross: result.amountGross, + amountNet: result.amountNet, + }), + ) + .code(201); + } catch (e) { + if (e.code === 'IDEMPOTENCY_COMPLETE') { + return h.response(error('VALIDATION_ERROR', e.message)).code(409); + } + if (e.code === 'GATEWAY_NOT_IMPLEMENTED' || e.code === 'PLATFORM_CHECKOUT_DISABLED' || e.code === 'PLATFORM_RAZORPAY_MISSING' || e.code === 'NO_GATEWAY' || e.code === 'IDEMPOTENCY_MISMATCH' || e.code === 'INVALID_AMOUNT' || e.code === 'RAZORPAY_SDK_MISSING') { + return h.response(error('VALIDATION_ERROR', e.message)).code(e.code === 'GATEWAY_NOT_IMPLEMENTED' ? 501 : 400); + } + throw e; + } +}; + +const publicVerify = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId).select('organization'); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const orgId = event.organization; + const { orderId, paymentId, signature } = request.payload; + const out = await verifyPaymentCore({ + orderId, + paymentId, + signature, + organizationId: orgId, + }); + if (out.error) { + const code = out.error === 'NOT_FOUND' ? 404 : 400; + return h.response(error(out.error, out.message)).code(code); + } + return h.response(success({ verified: true, transaction: out.transaction })); +}; + +const razorpayWebhook = async (request, h) => { + const raw = request.payload; + if (!Buffer.isBuffer(raw)) { + return h.response(error('VALIDATION_ERROR', 'Invalid body')).code(400).takeover(); + } + + let body; + try { + body = JSON.parse(raw.toString('utf8')); + } catch { + return h.response(error('VALIDATION_ERROR', 'Invalid JSON')).code(400).takeover(); + } + + const event = body.event; + const paymentEntity = body.payload?.payment?.entity; + const orderId = paymentEntity?.order_id; + const paymentId = paymentEntity?.id; + + if (!orderId) return h.response({ ok: true, ignored: true }); + + const tx = await PaymentTransaction.findOne({ externalOrderId: orderId }); + if (!tx) return h.response({ ok: true, ignored: true }); + + let ctx; + try { + ctx = await resolveForPaymentTransaction(tx); + } catch { + return h.response(error('SERVER_ERROR', 'Cannot resolve payment context')).code(500).takeover(); + } + + const sig = request.headers['x-razorpay-signature']; + if (!razorpayAdapter.verifyWebhookSignature(raw, sig, ctx.webhookSecret || ctx.keySecret)) { + return h.response(error('UNAUTHORIZED', 'Invalid webhook signature')).code(401).takeover(); + } + + if (tx.status === 'paid') return h.response({ ok: true, duplicate: true }); + + if (event === 'payment.captured' || event === 'order.paid') { + if (paymentId) await applyPaidStatus(tx, paymentId); + return h.response({ ok: true }); + } + + return h.response({ ok: true, ignored: event }); +}; + +const getTransaction = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('NOT_FOUND', 'Transaction not found')).code(404); + const tx = await PaymentTransaction.findOne({ + organization: orgId, + _id: request.params.id, + }) + .populate('event', 'name') + .populate('participant', 'email firstName lastName'); + if (!tx) return h.response(error('NOT_FOUND', 'Transaction not found')).code(404); + return h.response(success(tx)); +}; + +module.exports = { + listTransactions, + exportTransactions, + createOrder, + verify, + publicCreateOrder, + publicVerify, + razorpayWebhook, + getTransaction, +}; diff --git a/src/controllers/paymentProfile.controller.js b/src/controllers/paymentProfile.controller.js new file mode 100644 index 0000000..4543055 --- /dev/null +++ b/src/controllers/paymentProfile.controller.js @@ -0,0 +1,184 @@ +const OrganizationPaymentProfile = require('../models/OrganizationPaymentProfile'); +const { success, error } = require('../utils/response'); +const { encryptObject, decryptObject } = require('../utils/paymentCrypto'); +const { + getOrCreateProfile, + getProfileForApi, +} = require('../services/payments/organizationPaymentProfileService'); +const { publicGatewayShape } = require('../services/payments/maskCredentials'); +const { GATEWAY_PROVIDERS } = require('../models/OrganizationPaymentProfile'); + +function enrichGatewayRead(base, g) { + try { + const cred = decryptObject(g.credentialsEnc); + base.credentialsPublic = publicGatewayShape(g.provider, cred); + } catch { + base.credentialsPublic = {}; + } +} + +function assertSuperAdmin(user) { + return user.role === 'super_admin'; +} + +const getOrgPaymentProfile = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(success(null)); + const plain = await getProfileForApi(orgId, enrichGatewayRead); + return h.response(success(plain)); +}; + +const updateOrgPaymentProfile = async (request, h) => { + if (!assertSuperAdmin(request.auth.credentials.user)) { + return h.response(error('FORBIDDEN', 'Only super administrators can update payment profile')).code(403); + } + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + + const profile = await getOrCreateProfile(orgId); + const p = request.payload; + if (p.checkoutMode != null) { + if (!['platform_razorpay', 'custom'].includes(p.checkoutMode)) { + return h.response(error('VALIDATION_ERROR', 'Invalid checkoutMode')).code(400); + } + profile.checkoutMode = p.checkoutMode; + } + if (p.platformCommissionBps !== undefined) { + profile.platformCommissionBps = p.platformCommissionBps == null ? null : Number(p.platformCommissionBps); + } + if (p.defaultCurrency != null) profile.defaultCurrency = String(p.defaultCurrency).toUpperCase(); + if (p.taxRate !== undefined) profile.taxRate = p.taxRate; + if (p.taxType != null) profile.taxType = p.taxType; + await profile.save(); + const plain = await getProfileForApi(orgId, enrichGatewayRead); + return h.response(success(plain)); +}; + +function mergeCredentials(provider, existingEnc, incoming) { + let cur = {}; + try { + if (existingEnc) cur = decryptObject(existingEnc); + } catch { + cur = {}; + } + const next = { ...cur }; + if (incoming && typeof incoming === 'object') { + Object.keys(incoming).forEach((k) => { + const v = incoming[k]; + if (v != null && String(v).trim() !== '') next[k] = typeof v === 'string' ? v.trim() : v; + }); + } + if (provider !== 'razorpay') return encryptObject(next); + if (!next.keyId || !next.keySecret) { + const err = new Error('Razorpay requires keyId and keySecret'); + err.code = 'BAD_CREDENTIALS'; + throw err; + } + return encryptObject(next); +} + +const addGateway = async (request, h) => { + if (!assertSuperAdmin(request.auth.credentials.user)) { + return h.response(error('FORBIDDEN', 'Only super administrators can add payment gateways')).code(403); + } + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + + const { provider, label, credentials, isPrimary, testMode, isActive } = request.payload; + if (!GATEWAY_PROVIDERS.includes(provider)) { + return h.response(error('VALIDATION_ERROR', 'Invalid provider')).code(400); + } + + const profile = await getOrCreateProfile(orgId); + try { + const credentialsEnc = mergeCredentials(provider, '', credentials); + if (isPrimary) { + profile.gateways.forEach((g) => { + g.isPrimary = false; + }); + } + profile.gateways.push({ + provider, + label: label || provider, + isActive: isActive !== false, + isPrimary: Boolean(isPrimary), + testMode: testMode !== false, + credentialsEnc, + }); + await profile.save(); + } catch (e) { + if (e.code === 'BAD_CREDENTIALS') { + return h.response(error('VALIDATION_ERROR', e.message)).code(400); + } + throw e; + } + + const plain = await getProfileForApi(orgId, enrichGatewayRead); + return h.response(success(plain, 'Gateway added', 201)).code(201); +}; + +const patchGateway = async (request, h) => { + if (!assertSuperAdmin(request.auth.credentials.user)) { + return h.response(error('FORBIDDEN', 'Only super administrators can update payment gateways')).code(403); + } + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const { id } = request.params; + const profile = await getOrCreateProfile(orgId); + const g = profile.gateways.id(id); + if (!g) return h.response(error('NOT_FOUND', 'Gateway not found')).code(404); + + const p = request.payload; + if (p.label != null) g.label = p.label; + if (p.isActive != null) g.isActive = Boolean(p.isActive); + if (p.testMode != null) g.testMode = Boolean(p.testMode); + if (p.isPrimary === true) { + profile.gateways.forEach((gw) => { + gw.isPrimary = String(gw._id) === String(id); + }); + } + + if (p.credentials && typeof p.credentials === 'object') { + try { + g.credentialsEnc = mergeCredentials(g.provider, g.credentialsEnc, p.credentials); + } catch (e) { + if (e.code === 'BAD_CREDENTIALS') { + return h.response(error('VALIDATION_ERROR', e.message)).code(400); + } + throw e; + } + } + + await profile.save(); + const plain = await getProfileForApi(orgId, enrichGatewayRead); + return h.response(success(plain)); +}; + +const deleteGateway = async (request, h) => { + if (!assertSuperAdmin(request.auth.credentials.user)) { + return h.response(error('FORBIDDEN', 'Only super administrators can remove payment gateways')).code(403); + } + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const { id } = request.params; + const profile = await getOrCreateProfile(orgId); + const g = profile.gateways.id(id); + if (!g) return h.response(error('NOT_FOUND', 'Gateway not found')).code(404); + g.deleteOne(); + await profile.save(); + const plain = await getProfileForApi(orgId, enrichGatewayRead); + return h.response(success(plain)); +}; + +module.exports = { + getOrgPaymentProfile, + updateOrgPaymentProfile, + addGateway, + patchGateway, + deleteGateway, +}; diff --git a/src/controllers/platformPayment.controller.js b/src/controllers/platformPayment.controller.js new file mode 100644 index 0000000..26da79d --- /dev/null +++ b/src/controllers/platformPayment.controller.js @@ -0,0 +1,66 @@ +const PlatformPaymentConfig = require('../models/PlatformPaymentConfig'); +const { success, error } = require('../utils/response'); +const { encryptObject, encryptString, decryptObject, decryptString } = require('../utils/paymentCrypto'); +const { maskKeyId } = require('../services/payments/maskCredentials'); + +const getPlatformPaymentConfig = async (request, h) => { + if (request.auth.credentials.user.role !== 'super_admin') { + return h.response(error('FORBIDDEN', 'Only super administrators can view platform payment settings')).code(403); + } + let doc = await PlatformPaymentConfig.findOne({ key: 'default' }); + if (!doc) { + doc = await PlatformPaymentConfig.create({ key: 'default' }); + } + const out = doc.toObject(); + const webhookConfigured = Boolean(doc.webhookSecretEnc && String(doc.webhookSecretEnc).length > 4); + let cred = {}; + try { + if (out.razorpayCredentialsEnc) cred = decryptObject(out.razorpayCredentialsEnc); + } catch { + cred = {}; + } + delete out.razorpayCredentialsEnc; + delete out.webhookSecretEnc; + out.razorpayKeyIdMasked = cred.keyId ? maskKeyId(cred.keyId) : ''; + out.webhookConfigured = webhookConfigured; + return h.response(success(out)); +}; + +const updatePlatformPaymentConfig = async (request, h) => { + if (request.auth.credentials.user.role !== 'super_admin') { + return h.response(error('FORBIDDEN', 'Only super administrators can update platform payment settings')).code(403); + } + let doc = await PlatformPaymentConfig.findOne({ key: 'default' }); + if (!doc) doc = await PlatformPaymentConfig.create({ key: 'default' }); + + const p = request.payload; + if (p.enabled != null) doc.enabled = Boolean(p.enabled); + if (p.testMode != null) doc.testMode = Boolean(p.testMode); + if (p.defaultCommissionBps != null) doc.defaultCommissionBps = Number(p.defaultCommissionBps); + + if (p.razorpayKeyId != null || p.razorpayKeySecret != null) { + let cur = {}; + try { + if (doc.razorpayCredentialsEnc) cur = decryptObject(doc.razorpayCredentialsEnc); + } catch { + cur = {}; + } + const next = { ...cur }; + if (p.razorpayKeyId != null) next.keyId = String(p.razorpayKeyId).trim(); + if (p.razorpayKeySecret != null && String(p.razorpayKeySecret).length > 0) { + next.keySecret = String(p.razorpayKeySecret).trim(); + } + if (next.keyId && next.keySecret) { + doc.razorpayCredentialsEnc = encryptObject(next); + } + } + + if (p.webhookSecret != null && String(p.webhookSecret).trim().length > 0) { + doc.webhookSecretEnc = encryptString(String(p.webhookSecret).trim()); + } + + await doc.save(); + return getPlatformPaymentConfig(request, h); +}; + +module.exports = { getPlatformPaymentConfig, updatePlatformPaymentConfig }; diff --git a/src/controllers/question.controller.js b/src/controllers/question.controller.js new file mode 100644 index 0000000..40e05ea --- /dev/null +++ b/src/controllers/question.controller.js @@ -0,0 +1,130 @@ +const Question = require('../models/Question'); +const Event = require('../models/Event'); +const { success, error } = require('../utils/response'); +const { + syncSessionQuestionToFirebase, + removeSessionQuestionFromFirebase, +} = require('../utils/sessionQuestionFirebaseSync'); + +const list = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const questions = await Question.find({ event: eventId }) + .populate('participant user answers.answeredBy') + .sort({ createdAt: -1 }) + .lean(); + return h.response(success({ questions })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + const payload = { ...request.payload, event: eventId }; + if (user) { + payload.user = user._id; + } + const question = await Question.create(payload); + if (payload.session) { + const lean = await Question.findById(question._id).populate('participant').lean(); + await syncSessionQuestionToFirebase(lean); + } + return h.response(success(question, 'Question posted', 201)).code(201); +}; + +const get = async (request, h) => { + const question = await Question.findById(request.params.id).populate('event participant user answers.answeredBy'); + if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404); + const event = await Event.findById(question.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(question)); +}; + +const update = async (request, h) => { + const question = await Question.findById(request.params.id); + if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404); + const event = await Event.findById(question.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + Object.assign(question, request.payload); + await question.save(); + if (question.session) { + const lean = await Question.findById(question._id).populate('participant').lean(); + await syncSessionQuestionToFirebase(lean); + } + return h.response(success(question)); +}; + +const remove = async (request, h) => { + const question = await Question.findById(request.params.id); + if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404); + const event = await Event.findById(question.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const sessionId = question.session ? String(question.session) : null; + const qid = String(question._id); + await question.deleteOne(); + if (sessionId) await removeSessionQuestionFromFirebase(sessionId, qid); + return h.response(success(null, 'Question deleted')).code(200); +}; + +const upvote = async (request, h) => { + const question = await Question.findById(request.params.id); + if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404); + const userId = request.auth.credentials.id; + if (question.upvotedBy.some(id => id.toString() === userId)) { + question.upvotedBy = question.upvotedBy.filter(id => id.toString() !== userId); + question.upvotes = Math.max(0, question.upvotes - 1); + } else { + question.upvotedBy.push(userId); + question.upvotes += 1; + } + await question.save(); + if (question.session) { + const lean = await Question.findById(question._id).populate('participant').lean(); + await syncSessionQuestionToFirebase(lean); + } + return h.response(success(question)); +}; + +const answer = async (request, h) => { + const question = await Question.findById(request.params.id); + if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404); + const event = await Event.findById(question.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + question.answers.push({ + answeredBy: user._id, + answer: request.payload.answer, + isOfficial: request.payload.isOfficial || false, + }); + question.status = 'answered'; + await question.save(); + if (question.session) { + const lean = await Question.findById(question._id).populate('participant').lean(); + await syncSessionQuestionToFirebase(lean); + } + return h.response(success(question, 'Answer added', 201)).code(201); +}; + +const sessionQuestions = async (request, h) => { + const questions = await Question.find({ session: request.params.sessionId }).populate('participant user').lean(); + return h.response(success({ questions })); +}; + +module.exports = { list, create, get, update, remove, upvote, answer, sessionQuestions }; diff --git a/src/controllers/quizPoll.controller.js b/src/controllers/quizPoll.controller.js new file mode 100644 index 0000000..eb7ffd4 --- /dev/null +++ b/src/controllers/quizPoll.controller.js @@ -0,0 +1,138 @@ +const QuizPoll = require('../models/QuizPoll'); +const QuizPollResponse = require('../models/QuizPollResponse'); +const Event = require('../models/Event'); +const { success, error } = require('../utils/response'); +const { syncQuizPollToFirebase } = require('../utils/quizPollFirebaseSync'); + +const list = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const items = await QuizPoll.find({ event: eventId, deletedAt: null }).sort({ createdAt: -1 }).lean(); + return h.response(success({ quizPolls: items })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const item = await QuizPoll.create({ ...request.payload, event: eventId }); + await syncQuizPollToFirebase(item); + return h.response(success(item, 'Quiz/Poll created', 201)).code(201); +}; + +const get = async (request, h) => { + const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }).populate('event session').lean(); + if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + const event = await Event.findById(item.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(item)); +}; + +const update = async (request, h) => { + const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }); + if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + const event = await Event.findById(item.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const previousSessionId = item.session ? String(item.session) : null; + Object.assign(item, request.payload); + await item.save(); + await syncQuizPollToFirebase(item, previousSessionId); + return h.response(success(item)); +}; + +const remove = async (request, h) => { + const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }); + if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + const event = await Event.findById(item.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + item.deletedAt = new Date(); + await item.save(); + await syncQuizPollToFirebase(item); + return h.response(success(null, 'Quiz/Poll deleted')).code(200); +}; + +const publish = async (request, h) => { + const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }); + if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + item.settings = item.settings || {}; + item.settings.isLive = true; + await item.save(); + await syncQuizPollToFirebase(item); + return h.response(success(item, 'Published')); +}; + +const close = async (request, h) => { + const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }); + if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + item.settings = item.settings || {}; + item.settings.isLive = false; + await item.save(); + await syncQuizPollToFirebase(item); + return h.response(success(item, 'Closed')); +}; + +const results = async (request, h) => { + const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }); + if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + const event = await Event.findById(item.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const responses = await QuizPollResponse.find({ quizPoll: item._id }).populate('participant user').lean(); + const summary = responses.reduce((acc, r) => { + acc.totalResponses = (acc.totalResponses || 0) + 1; + acc.avgScore = ((acc.avgScore || 0) * (acc.totalResponses - 1) + (r.totalScore || 0)) / acc.totalResponses; + return acc; + }, {}); + return h.response(success({ quizPoll: item, responses, summary })); +}; + +const submitResponse = async (request, h) => { + const { quizPollId } = request.params; + const quizPoll = await QuizPoll.findOne({ _id: quizPollId, deletedAt: null }); + if (!quizPoll) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + const user = request.auth.credentials.user; + const response = await QuizPollResponse.create({ + quizPoll: quizPollId, + participant: request.payload.participantId, + user: user?._id, + responses: request.payload.responses, + totalScore: request.payload.totalScore, + timeSpent: request.payload.timeSpent, + }); + return h.response(success(response, 'Response submitted', 201)).code(201); +}; + +const listResponses = async (request, h) => { + const responses = await QuizPollResponse.find({ quizPoll: request.params.quizPollId }).populate('participant user').lean(); + return h.response(success({ responses })); +}; + +const sessionQuizPolls = async (request, h) => { + const items = await QuizPoll.find({ session: request.params.sessionId, deletedAt: null }).lean(); + return h.response(success({ quizPolls: items })); +}; + +module.exports = { + list, create, get, update, remove, publish, close, results, + submitResponse, listResponses, sessionQuizPolls, +}; diff --git a/src/controllers/rewardPoint.controller.js b/src/controllers/rewardPoint.controller.js new file mode 100644 index 0000000..6627e1d --- /dev/null +++ b/src/controllers/rewardPoint.controller.js @@ -0,0 +1,116 @@ +const RewardPoint = require('../models/RewardPoint'); +const Event = require('../models/Event'); +const Participant = require('../models/Participant'); +const { success, error } = require('../utils/response'); +const { REWARD_SOURCES } = require('../config/constants'); + +const list = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const points = await RewardPoint.find({ event: eventId }).populate('participant createdBy').sort({ createdAt: -1 }).lean(); + return h.response(success({ rewardPoints: points })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const reward = await RewardPoint.create({ + ...request.payload, + event: eventId, + createdBy: user._id, + }); + return h.response(success(reward, 'Reward points added', 201)).code(201); +}; + +const get = async (request, h) => { + const reward = await RewardPoint.findById(request.params.id).populate('participant event createdBy'); + if (!reward) return h.response(error('NOT_FOUND', 'Reward point record not found')).code(404); + const event = await Event.findById(reward.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(reward)); +}; + +const update = async (request, h) => { + const reward = await RewardPoint.findById(request.params.id); + if (!reward) return h.response(error('NOT_FOUND', 'Reward point record not found')).code(404); + const event = await Event.findById(reward.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + Object.assign(reward, request.payload); + await reward.save(); + return h.response(success(reward)); +}; + +const remove = async (request, h) => { + const reward = await RewardPoint.findById(request.params.id); + if (!reward) return h.response(error('NOT_FOUND', 'Reward point record not found')).code(404); + const event = await Event.findById(reward.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + await reward.deleteOne(); + return h.response(success(null, 'Reward points deleted')).code(200); +}; + +const byParticipant = async (request, h) => { + const points = await RewardPoint.find({ participant: request.params.participantId }).populate('event').lean(); + const total = points.filter(p => p.type === 'earned').reduce((s, p) => s + (p.points || 0), 0) + - points.filter(p => p.type === 'redeemed').reduce((s, p) => s + (p.points || 0), 0); + return h.response(success({ rewardPoints: points, total })); +}; + +const leaderboard = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const aggregated = await RewardPoint.aggregate([ + { $match: { event: event._id, type: 'earned' } }, + { $group: { _id: '$participant', total: { $sum: '$points' } } }, + { $sort: { total: -1 } }, + { $limit: 100 }, + { $lookup: { from: 'participants', localField: '_id', foreignField: '_id', as: 'p' } }, + { $unwind: '$p' }, + { $project: { participantId: '$_id', total: 1, firstName: '$p.firstName', lastName: '$p.lastName', email: '$p.email' } }, + ]); + return h.response(success({ leaderboard: aggregated })); +}; + +const bulk = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const created = await RewardPoint.insertMany( + (request.payload.entries || []).map(e => ({ + ...e, + event: eventId, + createdBy: user._id, + })) + ); + return h.response(success({ created: created.length }, 'Bulk reward points added', 201)).code(201); +}; + +module.exports = { list, create, get, update, remove, byParticipant, leaderboard, bulk }; diff --git a/src/controllers/session.controller.js b/src/controllers/session.controller.js new file mode 100644 index 0000000..c2c4fae --- /dev/null +++ b/src/controllers/session.controller.js @@ -0,0 +1,102 @@ +const Session = require('../models/Session'); +const Event = require('../models/Event'); +const Track = require('../models/Track'); +const { success, error } = require('../utils/response'); + +function normalizeSessionPayload(payload, eventId) { + const body = { ...payload }; + if (!body.track || String(body.track).trim() === '') { + delete body.track; + } + return body; +} + +const list = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const sessions = await Session.find({ event: eventId }).populate('speakers moderator track').sort({ startTime: 1 }).lean(); + return h.response(success({ sessions })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const event = await Event.findById(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const body = normalizeSessionPayload(request.payload, eventId); + if (body.track) { + const tr = await Track.findOne({ _id: body.track, event: eventId }); + if (!tr) return h.response(error('VALIDATION_ERROR', 'Track does not belong to this event')).code(400); + } + const session = await Session.create({ ...body, event: eventId }); + const populated = await Session.findById(session._id).populate('speakers moderator track').lean(); + return h.response(success(populated, 'Session created', 201)).code(201); +}; + +const get = async (request, h) => { + const session = await Session.findById(request.params.id).populate('speakers moderator event'); + if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404); + const event = await Event.findById(session.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(session)); +}; + +const update = async (request, h) => { + const session = await Session.findById(request.params.id); + if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404); + const event = await Event.findById(session.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const raw = { ...request.payload }; + const trackVal = raw.track && String(raw.track).trim() ? raw.track : null; + if (trackVal) { + const tr = await Track.findOne({ _id: trackVal, event: session.event }); + if (!tr) return h.response(error('VALIDATION_ERROR', 'Track does not belong to this event')).code(400); + } + delete raw.track; + Object.assign(session, raw); + session.track = trackVal; + await session.save(); + const populated = await Session.findById(session._id).populate('speakers moderator track').lean(); + return h.response(success(populated)); +}; + +const remove = async (request, h) => { + const session = await Session.findById(request.params.id); + if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404); + const event = await Event.findById(session.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + await session.deleteOne(); + return h.response(success(null, 'Session deleted')).code(200); +}; + +const updateStatus = async (request, h) => { + const session = await Session.findById(request.params.id); + if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404); + const event = await Event.findById(session.event); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + session.status = request.payload.status; + await session.save(); + return h.response(success(session)); +}; + +module.exports = { list, create, get, update, remove, updateStatus }; diff --git a/src/controllers/speaker.controller.js b/src/controllers/speaker.controller.js new file mode 100644 index 0000000..d81a69b --- /dev/null +++ b/src/controllers/speaker.controller.js @@ -0,0 +1,132 @@ +const mongoose = require('mongoose'); +const Speaker = require('../models/Speaker'); +const Event = require('../models/Event'); +const { success, error } = require('../utils/response'); + +function userOrgId(user) { + return user.organization?._id || user.organization; +} + +function normalizeSpeakerPayload(body) { + if (!body || typeof body !== 'object') return body; + const p = { ...body }; + if (p.profileVisibility != null && String(p.profileVisibility).trim() !== '') { + const v = String(p.profileVisibility).toLowerCase(); + if (v === 'public' || v === 'private') p.profileVisibility = v; + else delete p.profileVisibility; + } else { + delete p.profileVisibility; + } + if (p.logistics && typeof p.logistics === 'object') { + const L = { ...p.logistics }; + if (L.honorariumAmount != null && L.honorariumAmount !== '') { + L.honorariumAmount = String(L.honorariumAmount); + } + p.logistics = L; + } + return p; +} + +const list = async (request, h) => { + const user = request.auth.credentials.user; + const filter = {}; + if (user.role === 'super_admin') { + const orgIdRaw = request.query.organization || userOrgId(user); + if (!orgIdRaw) { + return h.response(error('VALIDATION_ERROR', 'Organization is required (query ?organization= or assign user to an org)')).code(400); + } + if (!mongoose.Types.ObjectId.isValid(String(orgIdRaw))) { + return h.response(error('VALIDATION_ERROR', 'Invalid organization id')).code(400); + } + filter.organization = orgIdRaw; + } else { + const oid = userOrgId(user); + if (!oid) { + return h.response(error('VALIDATION_ERROR', 'User has no organization')).code(400); + } + filter.organization = oid; + } + const { page = 1, limit = 20 } = request.query; + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); + const [speakers, total] = await Promise.all([ + Speaker.find(filter).skip(skip).limit(parseInt(limit, 10)).lean(), + Speaker.countDocuments(filter), + ]); + return h.response(success({ speakers, total, page: parseInt(page, 10), limit: parseInt(limit, 10) })) + .header('X-Total-Count', total); +}; + +const create = async (request, h) => { + const user = request.auth.credentials.user; + const orgIdRaw = + user.role === 'super_admin' && request.payload.organization + ? request.payload.organization + : userOrgId(user); + if (!orgIdRaw) { + return h.response(error('VALIDATION_ERROR', 'Organization required to create a speaker')).code(400); + } + if (!mongoose.Types.ObjectId.isValid(String(orgIdRaw))) { + return h.response(error('VALIDATION_ERROR', 'Invalid organization id')).code(400); + } + const orgId = new mongoose.Types.ObjectId(String(orgIdRaw)); + const payload = normalizeSpeakerPayload(request.payload); + delete payload.organization; + const speaker = await Speaker.create({ ...payload, organization: orgId }); + return h.response(success(speaker, 'Speaker added', 201)).code(201); +}; + +const get = async (request, h) => { + const speaker = await Speaker.findById(request.params.id).populate('sessions'); + if (!speaker) return h.response(error('NOT_FOUND', 'Speaker not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && speaker.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(speaker)); +}; + +const update = async (request, h) => { + const speaker = await Speaker.findById(request.params.id); + if (!speaker) return h.response(error('NOT_FOUND', 'Speaker not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && speaker.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const payload = normalizeSpeakerPayload(request.payload); + if (user.role === 'super_admin' && payload.organization != null && String(payload.organization).trim() !== '') { + if (!mongoose.Types.ObjectId.isValid(String(payload.organization))) { + return h.response(error('VALIDATION_ERROR', 'Invalid organization id')).code(400); + } + speaker.organization = payload.organization; + } + delete payload.organization; + Object.assign(speaker, payload); + if (!speaker.organization) { + return h.response(error('VALIDATION_ERROR', 'Speaker must belong to an organization')).code(400); + } + await speaker.save(); + return h.response(success(speaker)); +}; + +const remove = async (request, h) => { + const speaker = await Speaker.findById(request.params.id); + if (!speaker) return h.response(error('NOT_FOUND', 'Speaker not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && speaker.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + await speaker.deleteOne(); + return h.response(success(null, 'Speaker removed')).code(200); +}; + +const byEvent = async (request, h) => { + const event = await Event.findById(request.params.eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const Session = require('../models/Session'); + const sessions = await Session.find({ event: event._id }).populate('speakers'); + const speakerIds = [...new Set(sessions.flatMap(s => (s.speakers || []).map(sp => sp._id || sp)))]; + const speakers = await Speaker.find({ _id: { $in: speakerIds } }).lean(); + return h.response(success({ speakers })); +}; + +module.exports = { list, create, get, update, remove, byEvent }; diff --git a/src/controllers/subscription.controller.js b/src/controllers/subscription.controller.js new file mode 100644 index 0000000..17215bf --- /dev/null +++ b/src/controllers/subscription.controller.js @@ -0,0 +1,12 @@ +const { success } = require('../utils/response'); +const { listFeatures, listPlans } = require('../config/subscriptionCatalog'); + +const catalog = async (request, h) => + h.response( + success({ + features: listFeatures(), + plans: listPlans(), + }), + ); + +module.exports = { catalog }; diff --git a/src/controllers/track.controller.js b/src/controllers/track.controller.js new file mode 100644 index 0000000..f0c9eff --- /dev/null +++ b/src/controllers/track.controller.js @@ -0,0 +1,55 @@ +const Track = require('../models/Track'); +const Session = require('../models/Session'); +const Event = require('../models/Event'); +const { success, error } = require('../utils/response'); + +async function assertEventAccess(eventId, user) { + const event = await Event.findById(eventId); + if (!event) return { err: h => h.response(error('NOT_FOUND', 'Event not found')).code(404) }; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return { err: h => h.response(error('FORBIDDEN', 'Access denied')).code(403) }; + } + return { event }; +} + +const list = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + const tracks = await Track.find({ event: eventId }).sort({ sortOrder: 1, name: 1 }).lean(); + return h.response(success({ tracks })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + const track = await Track.create({ ...request.payload, event: eventId }); + return h.response(success(track, 'Track created', 201)).code(201); +}; + +const update = async (request, h) => { + const track = await Track.findById(request.params.id); + if (!track) return h.response(error('NOT_FOUND', 'Track not found')).code(404); + const user = request.auth.credentials.user; + const r = await assertEventAccess(track.event.toString(), user); + if (r.err) return r.err(h); + Object.assign(track, request.payload); + await track.save(); + return h.response(success(track)); +}; + +const remove = async (request, h) => { + const track = await Track.findById(request.params.id); + if (!track) return h.response(error('NOT_FOUND', 'Track not found')).code(404); + const user = request.auth.credentials.user; + const r = await assertEventAccess(track.event.toString(), user); + if (r.err) return r.err(h); + await Session.updateMany({ track: track._id }, { $unset: { track: 1 } }); + await track.deleteOne(); + return h.response(success(null, 'Track deleted')).code(200); +}; + +module.exports = { list, create, update, remove }; diff --git a/src/controllers/visitor.controller.js b/src/controllers/visitor.controller.js new file mode 100644 index 0000000..46f2706 --- /dev/null +++ b/src/controllers/visitor.controller.js @@ -0,0 +1,793 @@ +const mongoose = require('mongoose'); +const Session = require('../models/Session'); +const Track = require('../models/Track'); +const Event = require('../models/Event'); +const EventMicrosite = require('../models/EventMicrosite'); +const Question = require('../models/Question'); +const RewardPoint = require('../models/RewardPoint'); +const QuizPoll = require('../models/QuizPoll'); +const QuizPollResponse = require('../models/QuizPollResponse'); +const SessionBookmark = require('../models/SessionBookmark'); +const { success, error } = require('../utils/response'); +const { sendMicrositeContactEmail } = require('../utils/mail'); +const { syncSessionQuestionToFirebase } = require('../utils/sessionQuestionFirebaseSync'); +const { participantInEvent } = require('../utils/participantMembership'); + +/** All event fields for JSON except website, timestamps; adds logoUrl from website.logo || image. Never expose inviteCode. */ +function publicEventLoginPayload(doc) { + if (!doc) return null; + const { + website, + createdAt, + updatedAt, + _id, + organization, + settings, + ...rest + } = doc; + const logoUrl = + (doc.image && String(doc.image).trim()) || + (website?.logo && String(website.logo).trim()) || + ''; + let safeSettings = settings; + if (settings && typeof settings === 'object' && !Array.isArray(settings)) { + safeSettings = { ...settings }; + delete safeSettings.inviteCode; + } + const out = { + _id: _id != null ? String(_id) : _id, + organization: organization != null ? String(organization) : organization, + ...rest, + ...(safeSettings !== undefined ? { settings: safeSettings } : {}), + logoUrl, + }; + if (Object.prototype.hasOwnProperty.call(out, '__v')) delete out.__v; + return out; +} + +function normalizeFolderSlug(input) { + return String(input || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, ''); +} + +function normalizeJoinCode(input) { + return String(input || '').trim().toUpperCase(); +} + +async function resolveCode(request, h) { + const raw = request.payload?.code; + const code = String(raw ?? '').trim(); + if (!code) { + return h.response(error('VALIDATION_ERROR', 'Code is required')).code(400); + } + + const sessionCode = normalizeJoinCode(code); + if (sessionCode.length > 0) { + const session = await Session.findOne({ joinCode: sessionCode }).lean(); + if (session) { + const event = await Event.findById(session.event).lean(); + if (!event) { + return h.response(error('NOT_FOUND', 'Invalid code')).code(404); + } + return h.response( + success({ + type: 'session', + sessionId: session._id.toString(), + eventId: session.event.toString(), + }), + ); + } + + const track = await Track.findOne({ joinCode: sessionCode }).lean(); + if (track) { + const ev = await Event.findById(track.event).lean(); + if (!ev) { + return h.response(error('NOT_FOUND', 'Invalid code')).code(404); + } + return h.response( + success({ + type: 'track', + trackId: track._id.toString(), + eventId: track.event.toString(), + }), + ); + } + } + + const folder = normalizeFolderSlug(code); + if (!folder) { + return h.response(error('NOT_FOUND', 'Invalid code')).code(404); + } + + const microsite = await EventMicrosite.findOne({ publishFolder: folder }).select('event').lean(); + if (microsite && microsite.event) { + return h.response( + success({ + type: 'event', + eventId: microsite.event.toString(), + micrositeId: microsite._id.toString(), + }), + ); + } + + const event = await Event.findOne({ 'website.folder': folder }).lean(); + if (!event) { + return h.response(error('NOT_FOUND', 'Invalid code')).code(404); + } + + return h.response( + success({ + type: 'event', + eventId: event._id.toString(), + }), + ); +} + +async function loginPreview(request, h) { + const { eventId } = request.params; + if (!eventId || !mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + const event = await Event.findById(eventId).lean(); + if (!event) { + return h.response(error('NOT_FOUND', 'Event not found')).code(404); + } + return h.response(success(publicEventLoginPayload(event))); +} + +/** GET …/visitor/events/{eventId}/tracks — public; tracks for agenda links */ +async function listVisitorEventTracks(request, h) { + const { eventId } = request.params; + if (!eventId || !mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + const event = await Event.findById(eventId).lean(); + if (!event) { + return h.response(error('NOT_FOUND', 'Event not found')).code(404); + } + const tracks = await Track.find({ event: eventId }).sort({ name: 1 }).lean(); + return h.response(success({ tracks: tracks.map((t) => publicTrackPreview(t)) })); +} + +/** GET …/visitor/events/{eventId}/agenda — public; all non-cancelled sessions for schedule UI */ +async function listVisitorEventAgenda(request, h) { + const { eventId } = request.params; + if (!eventId || !mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + const event = await Event.findById(eventId).lean(); + if (!event) { + return h.response(error('NOT_FOUND', 'Event not found')).code(404); + } + const sessions = await Session.find({ + event: eventId, + status: { $ne: 'cancelled' }, + }) + .populate({ + path: 'speakers', + populate: { path: 'organization', select: 'name' }, + }) + .populate({ + path: 'moderator', + populate: { path: 'organization', select: 'name' }, + }) + .populate('track') + .sort({ startTime: 1 }) + .lean(); + return h.response(success({ sessions: sessions.map((s) => publicSessionPreview(s)) })); +} + +/** GET …/visitor/events/{eventId}/quiz-polls — public; session-scoped items for deep links */ +async function listVisitorEventQuizPolls(request, h) { + const { eventId } = request.params; + if (!eventId || !mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + const event = await Event.findById(eventId).lean(); + if (!event) { + return h.response(error('NOT_FOUND', 'Event not found')).code(404); + } + const items = await QuizPoll.find({ + event: eventId, + deletedAt: null, + session: { $exists: true, $ne: null }, + $or: [{ 'settings.status': { $exists: false } }, { 'settings.status': 'active' }], + }) + .select('title type session') + .sort({ title: 1 }) + .lean(); + const quizPolls = items + .filter((q) => q.session) + .map((q) => ({ + _id: String(q._id), + title: q.title != null ? q.title : '', + type: q.type != null ? q.type : 'poll', + session: String(q.session), + })); + return h.response(success({ quizPolls })); +} + +function publicSpeaker(doc) { + if (!doc || typeof doc !== 'object' || doc._id == null) return null; + const isPrivate = doc.profileVisibility === 'private'; + const expertise = Array.isArray(doc.expertise) + ? doc.expertise.map((e) => String(e || '').trim()).filter(Boolean) + : []; + const bioRaw = doc.bio != null ? String(doc.bio).trim() : ''; + const out = { + _id: String(doc._id), + firstName: doc.firstName, + lastName: doc.lastName, + title: doc.title, + photo: doc.photo, + speakerRole: doc.speakerRole, + ...(bioRaw ? { bio: bioRaw } : {}), + ...(expertise.length ? { expertise } : {}), + }; + if (!isPrivate) { + const orgFromRef = + doc.organization && typeof doc.organization === 'object' && doc.organization.name != null + ? String(doc.organization.name).trim() + : ''; + const orgFromField = doc.organizationName != null ? String(doc.organizationName).trim() : ''; + const organizationName = orgFromField || orgFromRef; + if (organizationName) { + out.organizationName = organizationName; + } + const sl = doc.socialLinks && typeof doc.socialLinks === 'object' ? doc.socialLinks : {}; + const socialLinks = {}; + for (const key of ['linkedin', 'twitter', 'website']) { + const v = sl[key]; + if (v != null && String(v).trim()) { + socialLinks[key] = String(v).trim(); + } + } + if (Object.keys(socialLinks).length) { + out.socialLinks = socialLinks; + } + } + return out; +} + +function publicSessionPreview(session) { + const speakers = (session.speakers || []).map(publicSpeaker).filter(Boolean); + const moderator = publicSpeaker(session.moderator); + let track = null; + if (session.track) { + const t = session.track; + if (t && typeof t === 'object' && t._id) { + track = { + _id: String(t._id), + name: t.name, + hall: t.hall, + trackType: t.trackType, + }; + } + } + return { + _id: String(session._id), + event: String(session.event), + track, + title: session.title, + description: session.description, + type: session.type, + startTime: session.startTime, + endTime: session.endTime, + location: session.location, + capacity: session.capacity, + status: session.status, + joinCode: session.joinCode, + speakers, + moderator, + }; +} + +/** Public session details for Eventbuzz; session must belong to event_id query. */ +async function sessionPreview(request, h) { + const { sessionId } = request.params; + const eventId = request.query.event_id; + if (!eventId || !mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(sessionId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event or session id')).code(400); + } + const session = await Session.findById(sessionId) + .populate({ + path: 'speakers', + populate: { path: 'organization', select: 'name' }, + }) + .populate({ + path: 'moderator', + populate: { path: 'organization', select: 'name' }, + }) + .populate('track') + .lean(); + if (!session || String(session.event) !== String(eventId)) { + return h.response(error('NOT_FOUND', 'Session not found')).code(404); + } + return h.response(success(publicSessionPreview(session))); +} + +function publicTrackPreview(track) { + return { + _id: String(track._id), + event: String(track.event), + name: track.name, + hall: track.hall, + trackType: track.trackType, + status: track.status, + joinCode: track.joinCode, + }; +} + +/** Public track details; track must belong to event_id query. */ +async function trackPreview(request, h) { + const { trackId } = request.params; + const eventId = request.query.event_id; + if (!eventId || !mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(trackId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event or track id')).code(400); + } + const track = await Track.findById(trackId).lean(); + if (!track || String(track.event) !== String(eventId)) { + return h.response(error('NOT_FOUND', 'Track not found')).code(404); + } + return h.response(success(publicTrackPreview(track))); +} + +function visitorQuestionAuthor(q) { + if (q.isAnonymous) return 'Anonymous'; + const p = q.participant; + if (!p || typeof p !== 'object') return 'Anonymous'; + if (p.displayName && String(p.displayName).trim()) return String(p.displayName).trim(); + const n = [p.firstName, p.lastName].filter(Boolean).join(' ').trim(); + if (n) return n; + if (p.email && String(p.email).trim()) return String(p.email).split('@')[0] || 'Guest'; + return 'Guest'; +} + +function publicVisitorQuestion(q) { + return { + _id: String(q._id), + author: visitorQuestionAuthor(q), + text: q.question, + up: q.upvotes ?? 0, + down: q.downvotes ?? 0, + createdAt: q.createdAt, + }; +} + +/** GET …/visitor/sessions/{id}/questions?event_id= */ +async function listSessionQuestions(request, h) { + const { sessionId } = request.params; + const eventId = request.query.event_id; + if (!mongoose.Types.ObjectId.isValid(sessionId) || !mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event or session id')).code(400); + } + const session = await Session.findById(sessionId).lean(); + if (!session || String(session.event) !== String(eventId)) { + return h.response(error('NOT_FOUND', 'Session not found')).code(404); + } + const rows = await Question.find({ session: sessionId, event: eventId }) + .populate('participant') + .sort({ createdAt: -1 }) + .lean(); + return h.response(success({ questions: rows.map(publicVisitorQuestion) })); +} + +/** POST …/visitor/sessions/{id}/questions?event_id= (optional jwt-attendee) */ +async function createSessionQuestion(request, h) { + const { sessionId } = request.params; + const eventId = request.query.event_id; + const { question: text, isAnonymous } = request.payload; + if (!mongoose.Types.ObjectId.isValid(sessionId) || !mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event or session id')).code(400); + } + const session = await Session.findById(sessionId).lean(); + if (!session || String(session.event) !== String(eventId)) { + return h.response(error('NOT_FOUND', 'Session not found')).code(404); + } + + let participantId; + const auth = request.auth; + if (auth.isAuthenticated && auth.credentials?.participant && !isAnonymous) { + const p = auth.credentials.participant; + if (participantInEvent(p, eventId)) { + participantId = p._id; + } + } + + const doc = await Question.create({ + event: eventId, + session: sessionId, + participant: participantId, + question: text, + isAnonymous: Boolean(isAnonymous), + }); + + const populated = await Question.findById(doc._id).populate('participant').lean(); + await syncSessionQuestionToFirebase(populated); + + return h.response(success({ question: publicVisitorQuestion(populated) }, 'Question submitted', 201)).code(201); +} + +function applyAudienceVoteDoc(question, voterKey, direction) { + const raw = question.audienceVotes; + const votes = Array.isArray(raw) ? raw.map((v) => ({ key: String(v.key), dir: v.dir === 'down' ? 'down' : 'up' })) : []; + const idx = votes.findIndex((v) => v.key === voterKey); + const prev = idx >= 0 ? votes[idx].dir : null; + + if (direction === 'up') { + if (prev === 'up') { + return; + } + if (prev === 'down') { + votes[idx].dir = 'up'; + question.upvotes = (question.upvotes || 0) + 1; + question.downvotes = Math.max(0, (question.downvotes || 0) - 1); + } else { + votes.push({ key: voterKey, dir: 'up' }); + question.upvotes = (question.upvotes || 0) + 1; + } + } else if (direction === 'down') { + if (prev === 'down') { + return; + } + if (prev === 'up') { + votes[idx].dir = 'down'; + question.downvotes = (question.downvotes || 0) + 1; + question.upvotes = Math.max(0, (question.upvotes || 0) - 1); + } else { + votes.push({ key: voterKey, dir: 'down' }); + question.downvotes = (question.downvotes || 0) + 1; + } + } + question.audienceVotes = votes; +} + +function audienceYourVote(question, voterKey) { + const v = (question.audienceVotes || []).find((x) => String(x.key) === voterKey); + return v ? v.dir : null; +} + +/** POST …/visitor/sessions/{sessionId}/questions/{questionId}/vote?event_id= */ +async function voteSessionQuestion(request, h) { + const { sessionId, questionId } = request.params; + const eventId = request.query.event_id; + const { voterKey, direction } = request.payload; + + if ( + !mongoose.Types.ObjectId.isValid(sessionId) || + !mongoose.Types.ObjectId.isValid(eventId) || + !mongoose.Types.ObjectId.isValid(questionId) + ) { + return h.response(error('VALIDATION_ERROR', 'Invalid ids')).code(400); + } + + const session = await Session.findById(sessionId).lean(); + if (!session || String(session.event) !== String(eventId)) { + return h.response(error('NOT_FOUND', 'Session not found')).code(404); + } + + const question = await Question.findOne({ + _id: questionId, + session: sessionId, + event: eventId, + }); + if (!question) { + return h.response(error('NOT_FOUND', 'Question not found')).code(404); + } + + const auth = request.auth; + let credParticipant = null; + if (auth && auth.isAuthenticated && auth.credentials && auth.credentials.participant) { + const p = auth.credentials.participant; + if (participantInEvent(p, eventId)) { + credParticipant = p; + } + } + + const audienceKey = credParticipant + ? String(credParticipant._id) + : String(voterKey).trim(); + + applyAudienceVoteDoc(question, audienceKey, direction); + + await question.save(); + + const populated = await Question.findById(question._id).populate('participant').lean(); + await syncSessionQuestionToFirebase(populated); + + const yv = audienceYourVote(question, audienceKey); + return h.response( + success({ + questionId: String(question._id), + up: question.upvotes ?? 0, + down: question.downvotes ?? 0, + yourVote: yv, + }), + ); +} + +/** POST …/visitor/events/{eventId}/quiz-polls/{quizPollId}/responses — attendee JWT */ +async function submitVisitorQuizPollResponse(request, h) { + const { eventId, quizPollId } = request.params; + const participant = request.auth.credentials.participant; + + if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(quizPollId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400); + } + if (!participantInEvent(participant, eventId)) { + return h.response(error('FORBIDDEN', 'Event mismatch')).code(403); + } + + const quizPoll = await QuizPoll.findOne({ _id: quizPollId, deletedAt: null }).lean(); + if (!quizPoll) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + if (String(quizPoll.event) !== String(eventId)) { + return h.response(error('FORBIDDEN', 'This poll does not belong to this event')).code(403); + } + + const settings = quizPoll.settings || {}; + if (settings.allowMultipleAttempts === false) { + const existing = await QuizPollResponse.findOne({ + quizPoll: quizPollId, + participant: participant._id, + }).lean(); + if (existing) { + return h.response(error('CONFLICT', 'You have already submitted a response')).code(409); + } + } + + const doc = await QuizPollResponse.create({ + quizPoll: quizPollId, + participant: participant._id, + responses: request.payload.responses || [], + totalScore: request.payload.totalScore, + timeSpent: request.payload.timeSpent, + }); + return h.response(success(doc, 'Response submitted', 201)).code(201); +} + +function normAnswerVal(ans) { + if (ans == null) return ''; + if (typeof ans === 'object' && ans !== null && typeof ans.toString === 'function') { + return String(ans.toString()); + } + return String(ans); +} + +/** + * Aggregate all stored responses for poll/survey questions (percent = share of respondents). + * @param {object} quizPoll lean doc + * @param {object[]} allResponses QuizPollResponse lean docs + */ +function buildVisitorPollResultsSummary(quizPoll, allResponses) { + const questions = quizPoll.questions || []; + const totalVoters = allResponses.length; + return questions.map((q, questionIndex) => { + const qType = String(q.type || 'single').toLowerCase(); + const base = { + questionIndex, + questionId: q._id != null ? String(q._id) : '', + type: q.type || 'single', + question: q.question != null ? q.question : '', + totalResponses: totalVoters, + }; + + const countForOptionBuckets = (buckets) => { + for (const doc of allResponses) { + const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex); + if (!item) continue; + const id = normAnswerVal(item.answer); + const b = buckets.find((x) => x.optionId === id); + if (b) b.count += 1; + } + buckets.forEach((b) => { + b.percent = totalVoters > 0 ? Math.round((b.count / totalVoters) * 1000) / 10 : 0; + }); + return buckets; + }; + + if (qType === 'text') { + let answered = 0; + for (const doc of allResponses) { + const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex); + if (!item) continue; + const t = normAnswerVal(item.answer).trim(); + if (t.length > 0) answered += 1; + } + return { + ...base, + kind: 'text', + answeredCount: answered, + percentAnswered: totalVoters > 0 ? Math.round((answered / totalVoters) * 1000) / 10 : 0, + }; + } + + if (qType === 'rating') { + const buckets = [1, 2, 3, 4, 5].map((n) => ({ + optionId: String(n), + text: String(n), + count: 0, + percent: 0, + })); + countForOptionBuckets(buckets); + return { ...base, kind: 'rating', options: buckets }; + } + + if (qType === 'multiple') { + const opts = (q.options || []).map((o) => ({ + optionId: o._id != null ? String(o._id) : '', + text: o.text != null ? o.text : '', + count: 0, + percent: 0, + })); + for (const doc of allResponses) { + const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex); + if (!item || !Array.isArray(item.answer)) continue; + for (const rawId of item.answer) { + const id = normAnswerVal(rawId); + const o = opts.find((x) => x.optionId === id); + if (o) o.count += 1; + } + } + opts.forEach((o) => { + o.percent = totalVoters > 0 ? Math.round((o.count / totalVoters) * 1000) / 10 : 0; + }); + return { ...base, kind: 'multiple', options: opts }; + } + + const opts = (q.options || []).map((o) => ({ + optionId: o._id != null ? String(o._id) : '', + text: o.text != null ? o.text : '', + count: 0, + percent: 0, + })); + countForOptionBuckets(opts); + return { ...base, kind: 'single', options: opts }; + }); +} + +/** GET …/visitor/events/{eventId}/quiz-polls/{quizPollId}/results-summary — poll/survey aggregates; attendee JWT */ +async function getVisitorQuizPollResultsSummary(request, h) { + const { eventId, quizPollId } = request.params; + const participant = request.auth.credentials.participant; + + if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(quizPollId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400); + } + if (!participantInEvent(participant, eventId)) { + return h.response(error('FORBIDDEN', 'Event mismatch')).code(403); + } + + const quizPoll = await QuizPoll.findOne({ _id: quizPollId, deletedAt: null }).lean(); + if (!quizPoll) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + if (String(quizPoll.event) !== String(eventId)) { + return h.response(error('FORBIDDEN', 'This poll does not belong to this event')).code(403); + } + const settings = quizPoll.settings || {}; + if (settings.showResults === false) { + return h.response(error('FORBIDDEN', 'Results are not available for this activity')).code(403); + } + + const t = String(quizPoll.type || '').toLowerCase(); + if (t !== 'poll' && t !== 'survey') { + return h.response(error('BAD_REQUEST', 'Aggregated results are only available for polls and surveys')).code(400); + } + + const all = await QuizPollResponse.find({ quizPoll: quizPollId }).lean(); + const questions = buildVisitorPollResultsSummary(quizPoll, all); + return h.response(success({ totalSubmissions: all.length, questions })); +} + +/** GET …/visitor/events/{eventId}/session-bookmarks — attendee JWT */ +async function listVisitorSessionBookmarks(request, h) { + const { eventId } = request.params; + const participant = request.auth.credentials.participant; + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + if (!participantInEvent(participant, eventId)) { + return h.response(error('FORBIDDEN', 'Event mismatch')).code(403); + } + const rows = await SessionBookmark.find({ participant: participant._id, event: eventId }) + .select('session') + .lean(); + const sessionIds = rows.map((r) => String(r.session)); + return h.response(success({ sessionIds })); +} + +/** PUT …/visitor/events/{eventId}/session-bookmarks — attendee JWT; body { sessionId, bookmarked } */ +async function putVisitorSessionBookmark(request, h) { + const { eventId } = request.params; + const { sessionId, bookmarked } = request.payload; + const participant = request.auth.credentials.participant; + if ( + !mongoose.Types.ObjectId.isValid(eventId) || + !mongoose.Types.ObjectId.isValid(sessionId) + ) { + return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400); + } + if (!participantInEvent(participant, eventId)) { + return h.response(error('FORBIDDEN', 'Event mismatch')).code(403); + } + const session = await Session.findById(sessionId).select('event').lean(); + if (!session || String(session.event) !== String(eventId)) { + return h.response(error('NOT_FOUND', 'Session not found')).code(404); + } + if (bookmarked) { + await SessionBookmark.findOneAndUpdate( + { participant: participant._id, session: sessionId }, + { + $set: { + event: eventId, + participant: participant._id, + session: sessionId, + }, + }, + { upsert: true, new: true }, + ); + } else { + await SessionBookmark.deleteOne({ participant: participant._id, session: sessionId }); + } + const rows = await SessionBookmark.find({ participant: participant._id, event: eventId }) + .select('session') + .lean(); + const sessionIds = rows.map((r) => String(r.session)); + return h.response(success({ sessionIds, bookmarked, sessionId: String(sessionId) })); +} + +/** GET …/visitor/events/{eventId}/reward-balance — attendee JWT; total points for this event only */ +async function visitorEventRewardBalance(request, h) { + const { eventId } = request.params; + const participant = request.auth.credentials.participant; + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + if (!participantInEvent(participant, eventId)) { + return h.response(error('FORBIDDEN', 'Event mismatch')).code(403); + } + const points = await RewardPoint.find({ participant: participant._id, event: eventId }).lean(); + const earned = points.filter((p) => p.type === 'earned').reduce((s, p) => s + (p.points || 0), 0); + const redeemed = points.filter((p) => p.type === 'redeemed').reduce((s, p) => s + (p.points || 0), 0); + const total = earned - redeemed; + return h.response(success({ total })); +} + +async function submitMicrositeContact(request, h) { + const p = request.payload; + if (p._gotcha) return h.response(error('VALIDATION_ERROR', 'Invalid request')).code(400); + const event = await Event.findById(p.eventId).select('name').lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const sent = await sendMicrositeContactEmail({ + to: p.notifyEmail, + eventName: event.name, + visitorEmail: p.email, + firstName: p.firstName, + lastName: p.lastName, + message: p.message, + }); + if (!sent) { + return h.response(error('SERVER_ERROR', 'Could not send message. Try again later.')).code(500); + } + return h.response(success({ ok: true }, 'Message sent', 201)).code(201); +} + +module.exports = { + resolveCode, + loginPreview, + listVisitorEventTracks, + listVisitorEventAgenda, + listVisitorEventQuizPolls, + sessionPreview, + trackPreview, + listSessionQuestions, + createSessionQuestion, + voteSessionQuestion, + submitVisitorQuizPollResponse, + getVisitorQuizPollResultsSummary, + listVisitorSessionBookmarks, + putVisitorSessionBookmark, + visitorEventRewardBalance, + submitMicrositeContact, +}; \ No newline at end of file diff --git a/src/controllers/visitorAuth.controller.js b/src/controllers/visitorAuth.controller.js new file mode 100644 index 0000000..ce5f8ae --- /dev/null +++ b/src/controllers/visitorAuth.controller.js @@ -0,0 +1,637 @@ +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); +const mongoose = require('mongoose'); +const Event = require('../models/Event'); +const Participant = require('../models/Participant'); +const VisitorAuthToken = require('../models/VisitorAuthToken'); +const { success, error } = require('../utils/response'); +const { sendVisitorOtpEmail, sendVisitorMagicLinkEmail } = require('../utils/mail'); +const { getMembership, membershipGuestQuery } = require('../utils/participantMembership'); + +const ATTENDEE_SECRET = + process.env.ATTENDEE_JWT_SECRET || process.env.JWT_SECRET || 'default-secret-change-in-production'; +const OTP_TTL_MS = 10 * 60 * 1000; +const LINK_TTL_MS = 15 * 60 * 1000; + +const rateBuckets = new Map(); +function rateLimit(key, max, windowMs) { + const now = Date.now(); + let b = rateBuckets.get(key); + if (!b || now > b.resetAt) { + b = { count: 0, resetAt: now + windowMs }; + rateBuckets.set(key, b); + } + b.count += 1; + return b.count <= max; +} + +function clientKey(request) { + return ( + request.headers['x-forwarded-for']?.split(',')[0]?.trim() || + request.info.remoteAddress || + 'unknown' + ); +} + +function getSettings(event) { + return event.settings || {}; +} + +function deadlinePassed(event) { + const d = getSettings(event).registrationDeadline; + if (!d) return false; + return new Date(d).getTime() <= Date.now(); +} + +function allowsNewRegistration(event) { + const s = getSettings(event); + if (s.registrationOpen === false) return false; + if (deadlinePassed(event)) return false; + return true; +} + +function registrationMode(event) { + return String(getSettings(event).registrationMode || 'Open').trim(); +} + +function checkInviteForNewReg(event, inviteToken) { + const mode = registrationMode(event); + if (mode !== 'Invite Only') return { ok: true }; + const expected = String(getSettings(event).inviteCode || '').trim(); + if (!expected) { + return { + ok: false, + err: error('INVITE_NOT_CONFIGURED', 'Invite-only registration is not configured for this event'), + }; + } + if (String(inviteToken || '').trim() !== expected) { + return { + ok: false, + err: error('REGISTRATION_INVITE_REQUIRED', 'A valid invite code is required'), + }; + } + return { ok: true }; +} + +function defaultParticipantStatus(event) { + const s = getSettings(event); + if (s.requiresApproval === true) return 'pending'; + if (registrationMode(event) === 'Approval Required') return 'pending'; + return 'confirmed'; +} + +function authTypeFromEvent(event) { + const v = String(getSettings(event).authenticationType || 'otp_email').trim(); + if (['otp_email', 'link_email', 'none'].includes(v)) return v; + return 'otp_email'; +} + +async function assertCapacity(event) { + const max = getSettings(event).maxParticipants; + if (!max || max <= 0) return { ok: true }; + const n = await Participant.countDocuments({ + eventMemberships: { $elemMatch: { event: event._id } }, + }); + if (n >= max) { + return { + ok: false, + err: error('REGISTRATION_FULL', 'This event has reached maximum participants'), + }; + } + return { ok: true }; +} + +function normalizeEmail(e) { + return String(e || '').trim().toLowerCase(); +} + +function hashOtp(otp, eventId, email) { + const pepper = process.env.OTP_PEPPER || 'eventbuzz-otp'; + return crypto.createHash('sha256').update(`${pepper}|${otp}|${eventId}|${email}`).digest('hex'); +} + +function hashLinkToken(raw) { + return crypto.createHash('sha256').update(raw).digest('hex'); +} + +function generateOtp() { + return String(Math.floor(100000 + Math.random() * 900000)); +} + +function signAttendeeJwt(participant, eventId, joinType, sessionId, trackId) { + return jwt.sign( + { + aud: 'attendee', + pid: String(participant._id), + eid: String(eventId), + scope: joinType || 'event', + sid: sessionId ? String(sessionId) : undefined, + tid: trackId ? String(trackId) : undefined, + }, + ATTENDEE_SECRET, + { expiresIn: '30d', algorithm: 'HS256' } + ); +} + +function customFieldsToObject(raw) { + if (!raw || typeof raw !== 'object') return {}; + if (raw instanceof Map) return Object.fromEntries(raw); + return { ...raw }; +} + +function sanitizeParticipant(p, eventId) { + if (!p) return null; + const o = p.toObject ? p.toObject() : { ...p }; + const m = getMembership(o, eventId); + const customFields = customFieldsToObject(o.customFields); + return { + _id: String(o._id), + event: String(eventId), + email: o.email, + firstName: o.firstName, + lastName: o.lastName, + displayName: o.displayName, + phone: o.phone, + guestToken: m?.guestToken, + status: m?.status, + emailVerified: o.emailVerified, + customFields: Object.keys(customFields).length ? customFields : undefined, + }; +} + +function customRegistrationFieldsIncomplete(event, participant) { + const defs = event.metadata && event.metadata.registrationCustomFields; + if (!Array.isArray(defs) || defs.length === 0) return false; + const po = participant.toObject ? participant.toObject() : participant; + const map = customFieldsToObject(po.customFields); + for (const def of defs) { + const code = String(def.code || '').trim(); + if (!code) continue; + const v = map[code]; + const ft = String(def.fieldType || 'text'); + if (ft === 'checkboxes') { + if (!Array.isArray(v) || v.length === 0) return true; + continue; + } + if (v === undefined || v === null) return true; + if (typeof v === 'string' && !v.trim()) return true; + if (typeof v === 'number' && Number.isNaN(v)) return true; + } + return false; +} + +function computeNeedsProfile(event, participant) { + if (customRegistrationFieldsIncomplete(event, participant)) return true; + const base = String(getSettings(event).registrationBaseFields || 'name_email'); + const po = participant.toObject ? participant.toObject() : participant; + if (base === 'name_email') { + return !(String(po.firstName || '').trim() && String(po.lastName || '').trim()); + } + if (base === 'name_email_phone') { + return !( + String(po.firstName || '').trim() && + String(po.lastName || '').trim() && + String(po.phone || '').trim() + ); + } + if (base === 'anonymous_name') { + return !String(po.displayName || '').trim(); + } + return false; +} + +function oidStr(id) { + if (!id) return undefined; + const s = typeof id === 'string' ? id : id.toString(); + if (!mongoose.Types.ObjectId.isValid(s)) return undefined; + return s; +} + +function authPayload(participant, event, eventId, joinType, sessionId, trackId) { + const token = signAttendeeJwt(participant, eventId, joinType, oidStr(sessionId), oidStr(trackId)); + const m = getMembership(participant, eventId); + return { + token, + participant: sanitizeParticipant(participant, eventId), + needsProfile: computeNeedsProfile(event, participant), + pendingApproval: m?.status === 'pending', + }; +} + +async function loadEvent(eventId) { + if (!mongoose.Types.ObjectId.isValid(eventId)) return null; + return Event.findById(eventId).lean(); +} + +async function startOtp(request, h) { + const { eventId } = request.params; + const { email, joinType = 'event', sessionId, trackId, inviteToken } = request.payload || {}; + const em = normalizeEmail(email); + if (!em) return h.response(error('VALIDATION_ERROR', 'Email is required')).code(400); + + if (!rateLimit(`visitorAuth:${clientKey(request)}`, 60, 60 * 60 * 1000)) { + return h.response(error('RATE_LIMIT', 'Too many requests. Try again later.')).code(429); + } + + const event = await loadEvent(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (authTypeFromEvent(event) !== 'otp_email') { + return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use email OTP sign-in')).code(400); + } + + const orgId = event.organization; + const existing = await Participant.findOne({ tenantOrganization: orgId, email: em }).lean(); + const inEvent = existing && getMembership(existing, eventId); + if (!inEvent && !allowsNewRegistration(event)) { + return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403); + } + if (!inEvent) { + const inv = checkInviteForNewReg(event, inviteToken); + if (!inv.ok) return h.response(inv.err).code(403); + const cap = await assertCapacity(event); + if (!cap.ok) return h.response(cap.err).code(403); + } + + const otp = generateOtp(); + const otpHash = hashOtp(otp, eventId, em); + const expiresAt = new Date(Date.now() + OTP_TTL_MS); + + await VisitorAuthToken.deleteMany({ eventId, email: em, kind: 'otp' }); + await VisitorAuthToken.create({ + kind: 'otp', + eventId, + email: em, + otpHash, + joinType, + sessionId: + sessionId && mongoose.Types.ObjectId.isValid(sessionId) + ? new mongoose.Types.ObjectId(sessionId) + : undefined, + trackId: + trackId && mongoose.Types.ObjectId.isValid(trackId) + ? new mongoose.Types.ObjectId(trackId) + : undefined, + expiresAt, + }); + + await sendVisitorOtpEmail({ to: em, otp, eventName: event.name }); + + return h.response(success({ ok: true })); +} + +async function verifyOtp(request, h) { + const { eventId } = request.params; + const { email, otp, inviteToken, firstName, lastName, phone, displayName } = request.payload || {}; + const em = normalizeEmail(email); + const code = String(otp || '').replace(/\D/g, '').slice(0, 6); + if (!em || code.length !== 6) { + return h.response(error('VALIDATION_ERROR', 'Valid email and 6-digit OTP are required')).code(400); + } + + const event = await loadEvent(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (authTypeFromEvent(event) !== 'otp_email') { + return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use email OTP sign-in')).code(400); + } + + const doc = await VisitorAuthToken.findOne({ + eventId, + email: em, + kind: 'otp', + expiresAt: { $gt: new Date() }, + }).sort({ createdAt: -1 }); + + if (!doc || doc.otpHash !== hashOtp(code, eventId, em)) { + return h.response(error('OTP_INVALID', 'Invalid or expired OTP')).code(400); + } + await VisitorAuthToken.deleteOne({ _id: doc._id }); + + const orgId = event.organization; + let participant = await Participant.findOne({ tenantOrganization: orgId, email: em }); + const inEvent = participant && getMembership(participant, eventId); + if (!inEvent) { + if (!allowsNewRegistration(event)) { + return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403); + } + const inv = checkInviteForNewReg(event, inviteToken); + if (!inv.ok) return h.response(inv.err).code(403); + const cap = await assertCapacity(event); + if (!cap.ok) return h.response(cap.err).code(403); + const status = defaultParticipantStatus(event); + if (!participant) { + participant = await Participant.create({ + tenantOrganization: orgId, + email: em, + firstName: firstName ? String(firstName).trim() : undefined, + lastName: lastName ? String(lastName).trim() : undefined, + phone: phone ? String(phone).trim() : undefined, + displayName: displayName ? String(displayName).trim() : undefined, + emailVerified: true, + eventMemberships: [ + { + event: eventId, + status, + registrationDate: new Date(), + }, + ], + }); + } else { + participant.eventMemberships.push({ + event: eventId, + status, + registrationDate: new Date(), + }); + participant.emailVerified = true; + if (firstName) participant.firstName = String(firstName).trim(); + if (lastName) participant.lastName = String(lastName).trim(); + if (phone) participant.phone = String(phone).trim(); + if (displayName) participant.displayName = String(displayName).trim(); + await participant.save(); + } + } else { + participant.emailVerified = true; + if (firstName) participant.firstName = String(firstName).trim(); + if (lastName) participant.lastName = String(lastName).trim(); + if (phone) participant.phone = String(phone).trim(); + if (displayName) participant.displayName = String(displayName).trim(); + await participant.save(); + } + + const joinType = doc.joinType || 'event'; + const sid = doc.sessionId; + const tid = doc.trackId; + return h.response(success(authPayload(participant, event, eventId, joinType, sid, tid))); +} + +async function startLink(request, h) { + const { eventId } = request.params; + const { email, joinType = 'event', sessionId, trackId, inviteToken } = request.payload || {}; + const em = normalizeEmail(email); + if (!em) return h.response(error('VALIDATION_ERROR', 'Email is required')).code(400); + + if (!rateLimit(`visitorAuth:${clientKey(request)}`, 60, 60 * 60 * 1000)) { + return h.response(error('RATE_LIMIT', 'Too many requests. Try again later.')).code(429); + } + + const event = await loadEvent(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (authTypeFromEvent(event) !== 'link_email') { + return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use magic link sign-in')).code(400); + } + + const orgId = event.organization; + const existing = await Participant.findOne({ tenantOrganization: orgId, email: em }).lean(); + const inEvent = existing && getMembership(existing, eventId); + if (!inEvent && !allowsNewRegistration(event)) { + return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403); + } + if (!inEvent) { + const inv = checkInviteForNewReg(event, inviteToken); + if (!inv.ok) return h.response(inv.err).code(403); + const cap = await assertCapacity(event); + if (!cap.ok) return h.response(cap.err).code(403); + } + + const rawToken = crypto.randomBytes(32).toString('hex'); + const tokenHash = hashLinkToken(rawToken); + const expiresAt = new Date(Date.now() + LINK_TTL_MS); + + await VisitorAuthToken.create({ + kind: 'link', + eventId, + email: em, + tokenHash, + joinType, + sessionId: + sessionId && mongoose.Types.ObjectId.isValid(sessionId) + ? new mongoose.Types.ObjectId(sessionId) + : undefined, + trackId: + trackId && mongoose.Types.ObjectId.isValid(trackId) + ? new mongoose.Types.ObjectId(trackId) + : undefined, + expiresAt, + }); + + const baseUrl = process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174'; + const magicLinkUrl = `${String(baseUrl).replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(rawToken)}`; + await sendVisitorMagicLinkEmail({ to: em, magicLinkUrl, eventName: event.name }); + + return h.response(success({ ok: true })); +} + +async function exchangeLink(request, h) { + const { token } = request.payload || {}; + const raw = String(token || '').trim(); + if (!raw) return h.response(error('VALIDATION_ERROR', 'Token is required')).code(400); + + const tokenHash = hashLinkToken(raw); + const doc = await VisitorAuthToken.findOne({ + kind: 'link', + tokenHash, + expiresAt: { $gt: new Date() }, + }); + if (!doc) return h.response(error('LINK_INVALID', 'Invalid or expired link')).code(400); + + const eventId = doc.eventId.toString(); + const event = await loadEvent(eventId); + if (!event) { + await VisitorAuthToken.deleteOne({ _id: doc._id }); + return h.response(error('NOT_FOUND', 'Event not found')).code(404); + } + if (authTypeFromEvent(event) !== 'link_email') { + await VisitorAuthToken.deleteOne({ _id: doc._id }); + return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use magic link sign-in')).code(400); + } + + const em = doc.email; + await VisitorAuthToken.deleteOne({ _id: doc._id }); + + const orgId = event.organization; + let participant = await Participant.findOne({ tenantOrganization: orgId, email: em }); + const inEvent = participant && getMembership(participant, eventId); + if (!inEvent) { + if (!allowsNewRegistration(event)) { + return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403); + } + const cap = await assertCapacity(event); + if (!cap.ok) return h.response(cap.err).code(403); + const status = defaultParticipantStatus(event); + if (!participant) { + participant = await Participant.create({ + tenantOrganization: orgId, + email: em, + emailVerified: true, + eventMemberships: [ + { + event: eventId, + status, + registrationDate: new Date(), + }, + ], + }); + } else { + participant.eventMemberships.push({ + event: eventId, + status, + registrationDate: new Date(), + }); + participant.emailVerified = true; + await participant.save(); + } + } else { + participant.emailVerified = true; + await participant.save(); + } + + const joinType = doc.joinType || 'event'; + return h.response( + success(authPayload(participant, event, eventId, joinType, doc.sessionId, doc.trackId)) + ); +} + +async function guestStart(request, h) { + const { eventId } = request.params; + const { joinType = 'event', sessionId, trackId, inviteToken, guestToken, displayName } = + request.payload || {}; + + const event = await loadEvent(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (authTypeFromEvent(event) !== 'none') { + return h.response(error('AUTH_FLOW_MISMATCH', 'This event requires email sign-in')).code(400); + } + + const orgId = event.organization; + const gt = String(guestToken || '').trim(); + if (gt) { + const existing = await Participant.findOne(membershipGuestQuery(orgId, eventId, gt)); + if (existing) { + const out = authPayload( + existing, + event, + eventId, + joinType, + oidStr(sessionId), + oidStr(trackId) + ); + const m = getMembership(existing, eventId); + return h.response(success({ ...out, guestToken: m?.guestToken })); + } + } + + if (!allowsNewRegistration(event)) { + return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403); + } + const inv = checkInviteForNewReg(event, inviteToken); + if (!inv.ok) return h.response(inv.err).code(403); + const cap = await assertCapacity(event); + if (!cap.ok) return h.response(cap.err).code(403); + + const newGuestToken = crypto.randomBytes(16).toString('hex'); + const status = defaultParticipantStatus(event); + const participant = await Participant.create({ + tenantOrganization: orgId, + displayName: displayName ? String(displayName).trim() : undefined, + emailVerified: false, + eventMemberships: [ + { + event: eventId, + guestToken: newGuestToken, + status, + registrationDate: new Date(), + }, + ], + }); + + const out = authPayload( + participant, + event, + eventId, + joinType, + oidStr(sessionId), + oidStr(trackId) + ); + return h.response(success({ ...out, guestToken: newGuestToken })); +} + +async function registrationStatus(request, h) { + const { eventId } = request.params; + const email = normalizeEmail(request.query.email); + if (!email) return h.response(error('VALIDATION_ERROR', 'email query parameter is required')).code(400); + const event = await loadEvent(eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const p = await Participant.findOne({ tenantOrganization: event.organization, email }).lean(); + const m = p && getMembership(p, eventId); + if (!m) return h.response(success({ registered: false })); + return h.response(success({ registered: true, status: m.status })); +} + +async function updateProfile(request, h) { + const { participant: credP } = request.auth.credentials; + const pid = credP._id || credP.id; + const { firstName, lastName, phone, displayName, customFields: incomingCustom } = request.payload || {}; + const participant = await Participant.findById(pid); + if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + + const eventIdCtx = request.auth.credentials.attendee?.eid; + const event = await loadEvent(eventIdCtx); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + if (firstName !== undefined) participant.firstName = String(firstName).trim() || undefined; + if (lastName !== undefined) participant.lastName = String(lastName).trim() || undefined; + if (phone !== undefined) participant.phone = String(phone).trim() || undefined; + if (displayName !== undefined) participant.displayName = String(displayName).trim() || undefined; + + if (incomingCustom && typeof incomingCustom === 'object' && !Array.isArray(incomingCustom)) { + const defs = (event.metadata && event.metadata.registrationCustomFields) || []; + const allowed = new Set(defs.map((d) => String(d.code || '').trim()).filter(Boolean)); + if (!participant.customFields || !(participant.customFields instanceof Map)) { + participant.customFields = new Map(); + } + for (const [key, val] of Object.entries(incomingCustom)) { + const k = String(key).trim(); + if (!allowed.has(k)) continue; + if (val === null || val === undefined || val === '') { + participant.customFields.delete(k); + continue; + } + if (Array.isArray(val)) { + participant.customFields.set( + k, + val.map((x) => String(x)).filter((s) => s.length > 0) + ); + } else if (typeof val === 'boolean' || typeof val === 'number') { + participant.customFields.set(k, val); + } else { + participant.customFields.set(k, String(val).trim()); + } + } + participant.markModified('customFields'); + } + + await participant.save(); + + const decoded = request.auth.credentials.attendee; + return h.response( + success( + authPayload( + participant, + event, + decoded.eid, + decoded.scope, + decoded.sid, + decoded.tid + ) + ) + ); +} + +module.exports = { + startOtp, + verifyOtp, + startLink, + exchangeLink, + guestStart, + registrationStatus, + updateProfile, +}; diff --git a/src/controllers/websiteSettings.controller.js b/src/controllers/websiteSettings.controller.js new file mode 100644 index 0000000..9175c2a --- /dev/null +++ b/src/controllers/websiteSettings.controller.js @@ -0,0 +1,242 @@ +const mongoose = require('mongoose'); +const Event = require('../models/Event'); +const EventMicrosite = require('../models/EventMicrosite'); +const { success, error } = require('../utils/response'); +const { WEBSITE_SECTION_TYPES } = require('../config/constants'); +const { + buildMicrositeHtml, + buildMicrositeDocumentHtml, + getConferenceCanvasStylePayload, + templateToWebsiteBag, +} = require('../utils/micrositeBuild'); +const { normalizePublishFolder, publishFolderAvailability } = require('../utils/publishFolderAvailability'); +const { runPublishToDisk } = require('../utils/runMicrositePublish'); + +const get = async (request, h) => { + const event = await Event.findById(request.params.eventId).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + const orgId = (event.organization && event.organization._id ? event.organization._id : event.organization)?.toString(); + if (user.role !== 'super_admin' && orgId !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(event.website || {})); +}; + +const update = async (request, h) => { + const event = await Event.findById(request.params.eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const payload = request.payload; + if (payload.folder !== undefined) { + const folder = (payload.folder || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, ''); + if (folder) { + const existing = await Event.findOne({ + 'website.folder': folder, + _id: { $ne: event._id }, + }); + if (existing) { + return h.response(error('VALIDATION_ERROR', 'Another event already uses this website folder')).code(400); + } + payload.folder = folder; + } else { + payload.folder = undefined; + } + } + const allowed = [ + 'enabled', + 'folder', + 'logo', + 'banners', + 'favicon', + 'title', + 'description', + 'keywords', + 'templateId', + 'layoutTemplate', + 'primaryColor', + 'accentColor', + 'fontPair', + 'registrationUrl', + 'registrationFormId', + 'seoDescription', + 'micrositeHtml', + ]; + event.website = event.website || {}; + allowed.forEach((key) => { + if (payload[key] !== undefined) event.website[key] = payload[key]; + }); + if (payload.layoutTemplate !== undefined) { + event.website.layoutTemplate = 'conference'; + } + if (payload.micrositeSections !== undefined) { + const ok = new Set(WEBSITE_SECTION_TYPES); + event.website.micrositeSections = Array.isArray(payload.micrositeSections) + ? payload.micrositeSections.filter((t) => typeof t === 'string' && ok.has(t)) + : []; + } + await event.save(); + return h.response(success(event.website)); +}; + +const publishMicrosite = async (request, h) => { + const event = await Event.findById(request.params.eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const micrositeId = request.payload && request.payload.micrositeId; + if (micrositeId && mongoose.Types.ObjectId.isValid(String(micrositeId))) { + try { + const ms = await EventMicrosite.findOne({ _id: micrositeId, event: event._id }).lean(); + if (!ms) { + return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404); + } + const result = await runPublishToDisk(event, ms, request.payload); + if (result.err) { + return h.response(error(result.err.code, result.err.message)).code(result.err.status); + } + return h.response(success(result.data)); + } catch (err) { + const msg = err && err.message ? err.message : 'Publish failed'; + return h.response(error('SERVER_ERROR', msg)).code(500); + } + } + + const staticRoot = process.env.MICROSITE_STATIC_ROOT; + if (!staticRoot || !String(staticRoot).trim()) { + return h + .response(error('NOT_CONFIGURED', 'Microsite publish target is not configured (MICROSITE_STATIC_ROOT).')) + .code(503); + } + const website = event.website || {}; + const folder = (website.folder || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, ''); + if (!folder) { + return h.response(error('VALIDATION_ERROR', 'Set a URL slug (website folder) before publishing.')).code(400); + } + try { + const regUrl = typeof website.registrationUrl === 'string' ? website.registrationUrl.trim() : ''; + const base = (process.env.MICROSITE_PUBLIC_BASE_URL || 'https://web.eventbuzz.live').replace(/\/$/, ''); + const targets = request.payload && request.payload.targets; + const draftFolder = `${folder}_draft`; + + const runOnce = async (publishFolderOverride) => { + return buildMicrositeHtml(event, { + staticRoot: String(staticRoot).trim(), + registrationUrl: regUrl || undefined, + publishFolderOverride, + }); + }; + + if (targets === 'draft') { + const { diskPath, publishFolder } = await runOnce(draftFolder); + return h.response( + success({ diskPath, publicUrl: `${base}/${publishFolder}/`, folder: publishFolder, targets: 'draft' }), + ); + } + if (targets === 'live') { + const { diskPath, publishFolder } = await runOnce(folder); + return h.response( + success({ diskPath, publicUrl: `${base}/${publishFolder}/`, folder: publishFolder, targets: 'live' }), + ); + } + if (targets === 'both') { + await runOnce(draftFolder); + const { diskPath, publishFolder } = await runOnce(folder); + return h.response( + success({ + diskPath, + publicUrl: `${base}/${publishFolder}/`, + draftPublicUrl: `${base}/${draftFolder}/`, + folder: publishFolder, + draftFolder, + targets: 'both', + }), + ); + } + + const { diskPath, publishFolder } = await buildMicrositeHtml(event, { + staticRoot: String(staticRoot).trim(), + registrationUrl: regUrl || undefined, + }); + const publicUrl = `${base}/${publishFolder}/`; + return h.response(success({ diskPath, publicUrl, folder: publishFolder })); + } catch (err) { + const msg = err && err.message ? err.message : 'Publish failed'; + return h.response(error('SERVER_ERROR', msg)).code(500); + } +}; + +const previewMicrositeHtml = async (request, h) => { + const event = await Event.findById(request.params.eventId).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + try { + const qMs = request.query.micrositeId; + if (qMs && mongoose.Types.ObjectId.isValid(String(qMs))) { + const ms = await EventMicrosite.findOne({ _id: qMs, event: event._id }).lean(); + if (!ms) { + return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404); + } + const website = templateToWebsiteBag(ms); + const regUrl = typeof website.registrationUrl === 'string' ? website.registrationUrl.trim() : ''; + const [docResult, canvasStyles] = await Promise.all([ + buildMicrositeDocumentHtml(event, { + previewOnly: true, + applyVisualMainOverride: false, + registrationUrl: regUrl || undefined, + website, + sourcePageId: ms.sourcePageId, + }), + getConferenceCanvasStylePayload(website), + ]); + return h.response(success({ html: docResult.html, canvasStyles })); + } + const regUrl = typeof event.website?.registrationUrl === 'string' ? event.website.registrationUrl.trim() : ''; + const [docResult, canvasStyles] = await Promise.all([ + buildMicrositeDocumentHtml(event, { + previewOnly: true, + applyVisualMainOverride: false, + registrationUrl: regUrl || undefined, + }), + getConferenceCanvasStylePayload(event.website || {}), + ]); + return h.response(success({ html: docResult.html, canvasStyles })); + } catch (err) { + const msg = err && err.message ? err.message : 'Preview failed'; + return h.response(error('SERVER_ERROR', msg)).code(400); + } +}; + +const checkFolder = async (request, h) => { + const event = await Event.findById(request.params.eventId); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const folder = normalizePublishFolder(request.query.folder || ''); + if (!folder) { + return h.response(success({ available: false, message: 'Folder is required' })); + } + let excludeMicrositeId = request.query.micrositeId || null; + if (excludeMicrositeId && !mongoose.Types.ObjectId.isValid(String(excludeMicrositeId))) { + excludeMicrositeId = null; + } + const { available, message } = await publishFolderAvailability( + folder, + event._id.toString(), + excludeMicrositeId, + ); + return h.response(success({ available, message })); +}; + +module.exports = { get, update, checkFolder, publishMicrosite, previewMicrositeHtml }; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..4f30b74 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,58 @@ +const jwt = require('jsonwebtoken'); +const User = require('../models/User'); +const { error } = require('../utils/response'); + +const JWT_SECRET = process.env.JWT_SECRET || 'default-secret'; + +const authMiddleware = async (request, h) => { + try { + const authHeader = request.headers.authorization; + const token = authHeader && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : request.state?.token; + + if (!token) { + return h.response(error('UNAUTHORIZED', 'No token provided')).code(401).takeover(); + } + + const decoded = jwt.verify(token, JWT_SECRET); + const user = await User.findById(decoded.id).select('-password').populate('organization'); + if (!user || !user.isActive) { + return h.response(error('UNAUTHORIZED', 'User not found or inactive')).code(401).takeover(); + } + + request.auth = { credentials: { user, id: user._id.toString() } }; + return h.continue; + } catch (err) { + if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') { + return h.response(error('UNAUTHORIZED', 'Invalid or expired token')).code(401).takeover(); + } + throw err; + } +}; + +const roleMiddleware = (allowedRoles) => ({ + async method(request, h) { + const user = request.auth?.credentials?.user; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401).takeover(); + if (!allowedRoles.includes(user.role)) { + return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403).takeover(); + } + return h.continue; + }, +}); + +const organizationMiddleware = async (request, h) => { + const user = request.auth?.credentials?.user; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401).takeover(); + const orgId = request.params.organizationId || request.params.eventId + ? (await require('../models/Event').findById(request.params.eventId).select('organization'))?.organization + : request.payload?.organization; + if (user.role === 'super_admin') return h.continue; + if (!user.organization || user.organization._id?.toString() !== orgId?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied to this organization')).code(403).takeover(); + } + return h.continue; +}; + +module.exports = { authMiddleware, roleMiddleware, organizationMiddleware }; diff --git a/src/models/AdminRequest.js b/src/models/AdminRequest.js new file mode 100644 index 0000000..8a7f91d --- /dev/null +++ b/src/models/AdminRequest.js @@ -0,0 +1,25 @@ +const mongoose = require('mongoose'); + +const adminRequestSchema = new mongoose.Schema( + { + type: { + type: String, + enum: ['data_export', 'account_deletion'], + required: true, + }, + requestedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, + status: { + type: String, + enum: ['pending', 'done', 'rejected'], + default: 'pending', + }, + message: { type: String, default: '' }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, + }, + { timestamps: true } +); + +adminRequestSchema.index({ status: 1, createdAt: -1 }); +adminRequestSchema.index({ organization: 1, type: 1 }); +module.exports = mongoose.model('AdminRequest', adminRequestSchema); diff --git a/src/models/Content.js b/src/models/Content.js new file mode 100644 index 0000000..a76a84d --- /dev/null +++ b/src/models/Content.js @@ -0,0 +1,37 @@ +const mongoose = require('mongoose'); + +const contentSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + name: String, + description: { type: String, default: '' }, + visibility: { + type: String, + enum: ['private', 'public'], + default: 'private', + }, + type: { + type: String, + enum: ['image', 'pdf', 'video', 'document', 'other'], + }, + url: String, + size: Number, + mimeType: String, + tags: [String], + collectionName: String, + // Used when media is stored on S3. + s3Key: String, + uploadedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + uploadedAt: { type: Date, default: Date.now }, + metadata: { type: Map, of: mongoose.Schema.Types.Mixed }, + }, + { timestamps: true } +); + +contentSchema.index({ organization: 1 }); +contentSchema.index({ type: 1 }); +module.exports = mongoose.model('Content', contentSchema); diff --git a/src/models/ContentCollection.js b/src/models/ContentCollection.js new file mode 100644 index 0000000..36e751d --- /dev/null +++ b/src/models/ContentCollection.js @@ -0,0 +1,35 @@ +const mongoose = require('mongoose'); + +const contentCollectionSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + index: true, + }, + name: { type: String, required: true }, + description: { type: String, default: '' }, + collectionType: { type: String, default: '' }, + coverIcon: { type: String, default: '' }, + tags: [{ type: String }], + contentIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'LibraryContent' }], + visibility: { + type: String, + enum: ['private', 'shared', 'public'], + default: 'private', + }, + sharedWith: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], + passwordProtected: { type: Boolean, default: false }, + passwordHash: { type: String, default: '' }, + expiryEnabled: { type: Boolean, default: false }, + expiryDate: Date, + notifyOnAdd: { type: Boolean, default: true }, + notifyOnShare: { type: Boolean, default: true }, + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + }, + { timestamps: true } +); + +contentCollectionSchema.index({ organization: 1, createdAt: -1 }); +module.exports = mongoose.model('ContentCollection', contentCollectionSchema); diff --git a/src/models/DesignTemplate.js b/src/models/DesignTemplate.js new file mode 100644 index 0000000..91b244b --- /dev/null +++ b/src/models/DesignTemplate.js @@ -0,0 +1,43 @@ +const mongoose = require('mongoose'); + +const variableSchema = new mongoose.Schema({ + name: String, + type: String, + defaultValue: String, + required: Boolean, +}); + +const designTemplateSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + name: { type: String, required: true }, + type: { + type: String, + enum: ['brochure', 'microsite', 'badge', 'emailer', 'certificate'], + required: true, + }, + category: String, + thumbnail: String, + content: mongoose.Schema.Types.Mixed, + variables: [variableSchema], + settings: { + isPublic: { type: Boolean, default: false }, + isActive: { type: Boolean, default: true }, + canDuplicate: { type: Boolean, default: true }, + }, + usedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Event' }], + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + }, + { timestamps: true } +); + +designTemplateSchema.index({ organization: 1, type: 1 }); +module.exports = mongoose.model('DesignTemplate', designTemplateSchema); diff --git a/src/models/Event.js b/src/models/Event.js new file mode 100644 index 0000000..1ea97a2 --- /dev/null +++ b/src/models/Event.js @@ -0,0 +1,104 @@ +const mongoose = require('mongoose'); + +const eventSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + name: { type: String, required: true }, + subtitle: String, + description: String, + type: { + type: String, + enum: ['conference', 'workshop', 'summit', 'cme', 'webinar'], + required: true, + }, + status: { + type: String, + enum: ['draft', 'upcoming', 'live', 'completed'], + default: 'draft', + }, + category: String, + startDate: { type: Date, required: true }, + endDate: { type: Date, required: true }, + location: String, + locationType: { + type: String, + enum: ['physical', 'virtual', 'hybrid'], + default: 'physical', + }, + virtualLink: String, + tags: [String], + image: String, + settings: { + registrationOpen: { type: Boolean, default: true }, + maxParticipants: Number, + requiresApproval: { type: Boolean, default: false }, + registrationMode: String, + verifyEmail: { type: Boolean, default: true }, + registrationDeadline: Date, + enableFeedback: { type: Boolean, default: true }, + enableRatings: { type: Boolean, default: false }, + /** otp_email | link_email | none */ + authenticationType: String, + /** name_email | email_only | name_email_phone | anonymous_name | anonymous */ + registrationBaseFields: String, + /** Optional shared code for Invite Only registration (visitor must pass inviteToken) */ + inviteCode: String, + }, + bannerImage: String, + website: { + enabled: { type: Boolean, default: false }, + folder: { type: String, trim: true, lowercase: true }, // URL slug, e.g. "napcon-2026" + logo: String, + banners: [String], + favicon: String, + title: String, + /** Meta description for published HTML (search snippet) */ + seoDescription: String, + description: String, + keywords: [String], + templateId: { type: String, default: 'default' }, + /** Microsite shell (conference only; legacy values tolerated until re-saved) */ + layoutTemplate: { + type: String, + default: 'conference', + }, + /** Ordered section types for generated static microsite (WEBSITE_SECTION_TYPES) */ + micrositeSections: [{ type: String }], + /** Conference microsite theme (generated inline :root on publish) */ + primaryColor: String, + accentColor: String, + /** Label from CreateMicrositePanel FONT_PAIRS, e.g. "Inter + Noto Sans" */ + fontPair: String, + /** Fallback for register_links CTAs when no registrationFormId / template */ + registrationUrl: String, + /** Registration Form _id; link built with MICROSITE_REGISTRATION_URL_TEMPLATE */ + registrationFormId: String, + /** GrapesJS / visual editor main markup (injected at publish when set; canonical store for custom HTML). */ + micrositeHtml: String, + }, + metadata: { + totalRevenue: { type: Number, default: 0 }, + rating: { type: Number, default: 0 }, + ratingCount: { type: Number, default: 0 }, + registrationCustomFields: [ + { + label: String, + code: String, + fieldType: String, + options: [{ value: String, label: String }], + }, + ], + }, + }, + { timestamps: true } +); + +eventSchema.index({ organization: 1 }); +eventSchema.index({ status: 1 }); +eventSchema.index({ startDate: 1 }); +eventSchema.index({ 'website.folder': 1 }); +module.exports = mongoose.model('Event', eventSchema); diff --git a/src/models/EventInvite.js b/src/models/EventInvite.js new file mode 100644 index 0000000..7e18669 --- /dev/null +++ b/src/models/EventInvite.js @@ -0,0 +1,19 @@ +const mongoose = require('mongoose'); + +const eventInviteSchema = new mongoose.Schema( + { + email: { type: String, required: true, lowercase: true, trim: true }, + event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true }, + organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true }, + invitedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + token: { type: String, required: true, unique: true, index: true }, + sessionIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Session' }], + eventRole: { type: String, default: 'Viewer' }, + expiresAt: { type: Date, required: true }, + status: { type: String, enum: ['pending', 'accepted', 'cancelled'], default: 'pending' }, + }, + { timestamps: true } +); + +eventInviteSchema.index({ event: 1, email: 1, status: 1 }); +module.exports = mongoose.model('EventInvite', eventInviteSchema); diff --git a/src/models/EventMicrosite.js b/src/models/EventMicrosite.js new file mode 100644 index 0000000..ecdea8c --- /dev/null +++ b/src/models/EventMicrosite.js @@ -0,0 +1,51 @@ +const mongoose = require('mongoose'); + +const eventMicrositeSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + index: true, + }, + /** Admin-only label (not published). */ + name: { type: String, trim: true }, + /** Public URL segment / static output folder (global uniqueness). */ + publishFolder: { + type: String, + trim: true, + lowercase: true, + sparse: true, + }, + enabled: { type: Boolean, default: false }, + logo: String, + banners: [String], + favicon: String, + title: String, + seoDescription: String, + description: String, + keywords: [String], + templateId: { type: String, default: 'default' }, + layoutTemplate: { type: String, default: 'conference' }, + micrositeSections: [{ type: String }], + primaryColor: String, + accentColor: String, + fontPair: String, + registrationUrl: String, + registrationFormId: { type: mongoose.Schema.Types.ObjectId, ref: 'Form' }, + micrositeHtml: String, + /** Optional EventPage whose `sections` supply block configs for this template. */ + sourcePageId: { type: mongoose.Schema.Types.ObjectId, ref: 'EventPage' }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + }, + { timestamps: true }, +); + +eventMicrositeSchema.index({ event: 1, createdAt: 1 }); +eventMicrositeSchema.index({ publishFolder: 1 }, { unique: true, sparse: true }); + +module.exports = mongoose.model('EventMicrosite', eventMicrositeSchema); diff --git a/src/models/EventPage.js b/src/models/EventPage.js new file mode 100644 index 0000000..6e83a7c --- /dev/null +++ b/src/models/EventPage.js @@ -0,0 +1,58 @@ +const mongoose = require('mongoose'); + +const seoSchema = new mongoose.Schema({ + title: String, + description: String, + keywords: [String], + image: String, +}); + +const sectionSchema = new mongoose.Schema( + { + id: { type: String, required: true }, + type: { type: String, required: true }, // WEBSITE_SECTION_TYPES + order: { type: Number, default: 0 }, + config: mongoose.Schema.Types.Mixed, + }, + { _id: false } +); + +const eventPageSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + title: { type: String, required: true }, + slug: { type: String, required: true }, + type: { + type: String, + enum: ['home', 'agenda', 'speakers', 'registration', 'sponsors', 'custom'], + required: true, + }, + sections: [sectionSchema], + /** Main-column HTML from the GrapesJS microsite visual editor (injected at publish as EB_INJECT_MAIN when present on Home). */ + micrositeHtml: { type: String }, + content: mongoose.Schema.Types.Mixed, + seo: seoSchema, + settings: { + isPublished: { type: Boolean, default: false }, + showInMenu: { type: Boolean, default: true }, + menuOrder: Number, + requireLogin: { type: Boolean, default: false }, + }, + views: { type: Number, default: 0 }, + lastViewed: Date, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + }, + { timestamps: true } +); + +eventPageSchema.index({ event: 1 }); +eventPageSchema.index({ slug: 1 }, { unique: true }); +module.exports = mongoose.model('EventPage', eventPageSchema); diff --git a/src/models/Form.js b/src/models/Form.js new file mode 100644 index 0000000..fc03803 --- /dev/null +++ b/src/models/Form.js @@ -0,0 +1,62 @@ +const mongoose = require('mongoose'); + +const formFieldSchema = new mongoose.Schema({ + label: String, + type: { + type: String, + enum: ['text', 'email', 'phone', 'select', 'radio', 'checkbox', 'checkboxes', 'file'], + }, + required: Boolean, + options: [String], + placeholder: String, + /** Registration: which Participant field this collects (`custom` uses customMapsKey → customFields). */ + mapsTo: String, + customMapsKey: String, + isFixed: { type: Boolean, default: false }, +}); + +const formSchema = new mongoose.Schema( + { + tenantOrganization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + index: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: false, + }, + name: { type: String, required: true }, + description: { type: String, default: '' }, + type: { + type: String, + enum: ['registration', 'feedback', 'survey', 'custom', 'other'], + required: true, + }, + fields: [formFieldSchema], + settings: { + isActive: { type: Boolean, default: true }, + allowMultiple: { type: Boolean, default: false }, + submissionLimit: Number, + thankYouMessage: String, + responseDeadline: Date, + showThankYou: { type: Boolean, default: true }, + collectEmail: { type: Boolean, default: false }, + allowAnonymous: { type: Boolean, default: false }, + paymentEnabled: { type: Boolean, default: false }, + paymentAmount: String, + sendConfirmationEmail: { type: Boolean, default: true }, + /** Registration: tags applied to participant after each submission. */ + onSubmitAddTags: { type: [String], default: [] }, + /** Registration: fixed participant list to add submitter to. */ + onSubmitParticipantListId: { type: mongoose.Schema.Types.ObjectId, ref: 'ParticipantList' }, + }, + }, + { timestamps: true } +); + +formSchema.index({ event: 1 }); +formSchema.index({ tenantOrganization: 1, createdAt: -1 }); +module.exports = mongoose.model('Form', formSchema); diff --git a/src/models/FormSubmission.js b/src/models/FormSubmission.js new file mode 100644 index 0000000..0d7608c --- /dev/null +++ b/src/models/FormSubmission.js @@ -0,0 +1,40 @@ +const mongoose = require('mongoose'); + +const formSubmissionSchema = new mongoose.Schema( + { + form: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Form', + required: true, + }, + tenantOrganization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + index: true, + }, + participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }, + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: false, + }, + data: { type: Map, of: mongoose.Schema.Types.Mixed }, + status: { + type: String, + enum: ['submitted', 'reviewed', 'approved', 'rejected'], + default: 'submitted', + }, + submittedAt: { type: Date, default: Date.now }, + reviewedAt: Date, + reviewedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + notes: String, + }, + { timestamps: true } +); + +formSubmissionSchema.index({ form: 1 }); +formSubmissionSchema.index({ participant: 1 }); +formSubmissionSchema.index({ tenantOrganization: 1 }); +module.exports = mongoose.model('FormSubmission', formSubmissionSchema); diff --git a/src/models/LibraryContent.js b/src/models/LibraryContent.js new file mode 100644 index 0000000..6a503d9 --- /dev/null +++ b/src/models/LibraryContent.js @@ -0,0 +1,37 @@ +const mongoose = require('mongoose'); + +/** Org-scoped files shown only in Portfolio → Library (uploads with module content-library). */ +const libraryContentSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + name: String, + description: { type: String, default: '' }, + visibility: { + type: String, + enum: ['private', 'public'], + default: 'private', + }, + type: { + type: String, + enum: ['image', 'pdf', 'video', 'document', 'other'], + }, + url: String, + size: Number, + mimeType: String, + tags: [String], + collectionName: String, + s3Key: String, + uploadedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + uploadedAt: { type: Date, default: Date.now }, + metadata: { type: Map, of: mongoose.Schema.Types.Mixed }, + }, + { timestamps: true } +); + +libraryContentSchema.index({ organization: 1 }); +libraryContentSchema.index({ type: 1 }); +module.exports = mongoose.model('LibraryContent', libraryContentSchema); diff --git a/src/models/MessagingCampaign.js b/src/models/MessagingCampaign.js new file mode 100644 index 0000000..bbd17d8 --- /dev/null +++ b/src/models/MessagingCampaign.js @@ -0,0 +1,65 @@ +const mongoose = require('mongoose'); + +const recipientsSchema = new mongoose.Schema({ + type: { + type: String, + enum: ['all', 'filtered', 'custom'], + }, + filters: { type: Map, of: mongoose.Schema.Types.Mixed }, + customList: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }], +}); + +const contentSchema = new mongoose.Schema({ + subject: String, + body: String, + template: String, + variables: { type: Map, of: mongoose.Schema.Types.Mixed }, +}); + +const campaignSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + name: { type: String, required: true }, + channel: { + type: String, + enum: ['email', 'whatsapp', 'sms'], + required: true, + }, + type: { + type: String, + enum: ['broadcast', 'trigger', 'scheduled'], + required: true, + }, + triggerEvent: String, + recipients: recipientsSchema, + content: contentSchema, + status: { + type: String, + enum: ['draft', 'scheduled', 'sending', 'sent', 'failed'], + default: 'draft', + }, + scheduledFor: Date, + sentAt: Date, + stats: { + total: Number, + sent: Number, + delivered: Number, + opened: Number, + clicked: Number, + failed: Number, + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + }, + { timestamps: true } +); + +campaignSchema.index({ event: 1 }); +module.exports = mongoose.model('MessagingCampaign', campaignSchema); diff --git a/src/models/Notification.js b/src/models/Notification.js new file mode 100644 index 0000000..e848fea --- /dev/null +++ b/src/models/Notification.js @@ -0,0 +1,41 @@ +const mongoose = require('mongoose'); + +const notificationSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' }, + type: { + type: String, + enum: ['system', 'event', 'message', 'reminder', 'alert'], + }, + title: { type: String, required: true }, + message: String, + data: { type: Map, of: mongoose.Schema.Types.Mixed }, + priority: { + type: String, + enum: ['low', 'medium', 'high', 'urgent'], + default: 'medium', + }, + channel: { + type: String, + enum: ['in_app', 'email', 'push', 'sms'], + default: 'in_app', + }, + status: { + type: String, + enum: ['unread', 'read', 'archived'], + default: 'unread', + }, + readAt: Date, + actionUrl: String, + expiresAt: Date, + }, + { timestamps: true } +); + +notificationSchema.index({ user: 1, status: 1 }); +module.exports = mongoose.model('Notification', notificationSchema); diff --git a/src/models/Organization.js b/src/models/Organization.js new file mode 100644 index 0000000..f57f3ef --- /dev/null +++ b/src/models/Organization.js @@ -0,0 +1,32 @@ +const mongoose = require('mongoose'); + +const organizationSchema = new mongoose.Schema( + { + name: { type: String, required: true }, + description: String, + email: String, + phone: String, + website: String, + address: String, + logo: String, + timezone: { type: String, default: 'Asia/Kolkata' }, + dateFormat: { type: String, default: 'DD/MM/YYYY' }, + language: { type: String, default: 'en-IN' }, + subscription: { + plan: { type: String, enum: ['free', 'pro', 'enterprise'], default: 'free' }, + status: { + type: String, + enum: ['active', 'trial', 'past_due', 'cancelled'], + default: 'active', + }, + expiresAt: Date, + maxEvents: Number, + maxParticipants: Number, + featureOverrides: [{ type: String }], + }, + }, + { timestamps: true } +); + +organizationSchema.index({ name: 1 }); +module.exports = mongoose.model('Organization', organizationSchema); diff --git a/src/models/OrganizationPaymentProfile.js b/src/models/OrganizationPaymentProfile.js new file mode 100644 index 0000000..525ea69 --- /dev/null +++ b/src/models/OrganizationPaymentProfile.js @@ -0,0 +1,48 @@ +const mongoose = require('mongoose'); + +const GATEWAY_PROVIDERS = ['razorpay', 'payu', 'ccavenue', 'cashfree', 'stripe', 'paytm']; + +const orgGatewaySchema = new mongoose.Schema( + { + provider: { + type: String, + enum: GATEWAY_PROVIDERS, + required: true, + }, + label: { type: String, default: '' }, + isActive: { type: Boolean, default: true }, + isPrimary: { type: Boolean, default: false }, + testMode: { type: Boolean, default: true }, + /** AES-GCM encrypted JSON — provider-specific secrets */ + credentialsEnc: { type: String, required: true }, + }, + { timestamps: true } +); + +const organizationPaymentProfileSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + unique: true, + }, + checkoutMode: { + type: String, + enum: ['platform_razorpay', 'custom'], + default: 'platform_razorpay', + }, + /** Override platform default when using platform checkout */ + platformCommissionBps: { type: Number, default: null }, + defaultCurrency: { type: String, default: 'INR' }, + taxRate: { type: Number, default: null }, + taxType: { type: String, default: '' }, + gateways: { type: [orgGatewaySchema], default: [] }, + }, + { timestamps: true } +); + +organizationPaymentProfileSchema.index({ organization: 1 }); + +module.exports = mongoose.model('OrganizationPaymentProfile', organizationPaymentProfileSchema); +module.exports.GATEWAY_PROVIDERS = GATEWAY_PROVIDERS; diff --git a/src/models/OrganizationUserMap.js b/src/models/OrganizationUserMap.js new file mode 100644 index 0000000..8fe1cf3 --- /dev/null +++ b/src/models/OrganizationUserMap.js @@ -0,0 +1,31 @@ +const mongoose = require('mongoose'); + +const eventAccessSchema = new mongoose.Schema({ + event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true }, + sessionIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Session' }], + /** Event-specific role label (Moderator, Presenter, etc.) */ + eventRole: { type: String, default: 'Viewer' }, +}, { _id: false }); + +const organizationUserMapSchema = new mongoose.Schema( + { + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true }, + role: { + type: String, + enum: ['super_admin', 'admin', 'moderator', 'viewer'], + required: true, + }, + eventAccess: { + type: [eventAccessSchema], + default: [], + }, + }, + { timestamps: true } +); + +organizationUserMapSchema.index({ user: 1, organization: 1 }, { unique: true }); +organizationUserMapSchema.index({ organization: 1 }); +organizationUserMapSchema.index({ user: 1 }); + +module.exports = mongoose.model('OrganizationUserMap', organizationUserMapSchema); diff --git a/src/models/Participant.js b/src/models/Participant.js new file mode 100644 index 0000000..01d8ddb --- /dev/null +++ b/src/models/Participant.js @@ -0,0 +1,73 @@ +const mongoose = require('mongoose'); + +const eventMembershipSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + status: { + type: String, + enum: ['pending', 'confirmed', 'checked_in', 'cancelled'], + default: 'pending', + }, + checkedIn: { type: Boolean, default: false }, + checkedInAt: Date, + guestToken: { type: String, trim: true }, + registrationDate: { type: Date, default: Date.now }, + /** @deprecated Prefer root-level `Participant.tags`. Loaded for legacy rows only. */ + tags: [String], + }, + { _id: false } +); + +const participantSchema = new mongoose.Schema( + { + /** Dashboard tenant (organization that owns this person record). */ + tenantOrganization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + email: { type: String, trim: true, lowercase: true }, + firstName: { type: String, trim: true }, + lastName: { type: String, trim: true }, + displayName: { type: String, trim: true }, + phone: String, + /** Employer / company (string). Not the tenant org id. */ + organization: String, + designation: String, + customFields: { type: Map, of: mongoose.Schema.Types.Mixed }, + payment: { + amount: Number, + currency: { type: String, default: 'INR' }, + status: { + type: String, + enum: ['pending', 'paid', 'failed', 'refunded'], + }, + transactionId: String, + paidAt: Date, + }, + notes: String, + emailVerified: { type: Boolean, default: false }, + /** Admin labels (VIP, Speaker, …) — organization-wide on the person, not per-event. */ + tags: { type: [String], default: [] }, + eventMemberships: { type: [eventMembershipSchema], default: [] }, + }, + { timestamps: true } +); + +participantSchema.index({ tenantOrganization: 1, status: 1 }); +participantSchema.index( + { tenantOrganization: 1, email: 1 }, + { + unique: true, + partialFilterExpression: { + email: { $exists: true, $type: 'string', $gt: '' }, + }, + } +); +participantSchema.index({ 'eventMemberships.event': 1, 'eventMemberships.status': 1 }); + +module.exports = mongoose.model('Participant', participantSchema); diff --git a/src/models/ParticipantList.js b/src/models/ParticipantList.js new file mode 100644 index 0000000..2b3190d --- /dev/null +++ b/src/models/ParticipantList.js @@ -0,0 +1,26 @@ +const mongoose = require('mongoose'); + +const participantListSchema = new mongoose.Schema( + { + name: { type: String, required: true, trim: true }, + description: { type: String, trim: true, default: '' }, + tenantOrganization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + visibility: { type: String, enum: ['private', 'shared'], default: 'private' }, + type: { type: String, enum: ['fixed', 'rule_based'], required: true }, + autoUpdate: { type: Boolean, default: true }, + /** UI rule rows: { field, operator, value, id? }; conjunction: AND | OR */ + rules: { type: mongoose.Schema.Types.Mixed, default: null }, + /** Fixed lists: ordered participant ids (import / manual). Rule-based lists ignore this. */ + participantIds: { type: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }], default: [] }, + }, + { timestamps: true } +); + +participantListSchema.index({ tenantOrganization: 1, name: 1 }); + +module.exports = mongoose.model('ParticipantList', participantListSchema); diff --git a/src/models/PaymentConfig.js b/src/models/PaymentConfig.js new file mode 100644 index 0000000..289d85d --- /dev/null +++ b/src/models/PaymentConfig.js @@ -0,0 +1,27 @@ +const mongoose = require('mongoose'); + +const paymentConfigSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + country: { type: String, required: true }, + gateway: { + type: String, + enum: ['razorpay', 'stripe', 'paypal', 'manual'], + required: true, + }, + credentials: { type: Map, of: String }, + currency: { type: String, default: 'INR' }, + taxRate: Number, + taxType: String, + isActive: { type: Boolean, default: false }, + testMode: { type: Boolean, default: true }, + }, + { timestamps: true } +); + +paymentConfigSchema.index({ organization: 1 }, { unique: true }); +module.exports = mongoose.model('PaymentConfig', paymentConfigSchema); diff --git a/src/models/PaymentTransaction.js b/src/models/PaymentTransaction.js new file mode 100644 index 0000000..856cd51 --- /dev/null +++ b/src/models/PaymentTransaction.js @@ -0,0 +1,57 @@ +const mongoose = require('mongoose'); + +const paymentTransactionSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + default: null, + }, + participant: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Participant', + default: null, + }, + formId: { type: mongoose.Schema.Types.ObjectId, default: null }, + checkoutSource: { + type: String, + enum: ['platform_razorpay', 'custom_gateway'], + required: true, + }, + gatewayProvider: { type: String, required: true }, + orgGatewayId: { type: mongoose.Schema.Types.ObjectId, default: null }, + currency: { type: String, default: 'INR' }, + /** Amounts in smallest currency unit (e.g. paise) */ + amountSubtotal: { type: Number, required: true }, + commissionAmount: { type: Number, default: 0 }, + amountGross: { type: Number, required: true }, + amountNet: { type: Number, required: true }, + feeEstimate: { type: Number, default: null }, + externalOrderId: { type: String, default: '' }, + externalPaymentId: { type: String, default: '' }, + status: { + type: String, + enum: ['pending', 'paid', 'failed', 'refunded'], + default: 'pending', + }, + idempotencyKey: { type: String, default: '' }, + /** Razorpay key_id used when order was created — must match on verify after resolve */ + ledgerKeyId: { type: String, default: '' }, + registrationContext: { type: mongoose.Schema.Types.Mixed, default: null }, + }, + { timestamps: true } +); + +paymentTransactionSchema.index({ organization: 1, createdAt: -1 }); +paymentTransactionSchema.index({ organization: 1, idempotencyKey: 1 }, { unique: true, partialFilterExpression: { idempotencyKey: { $gt: '' } } }); +paymentTransactionSchema.index({ event: 1 }); +paymentTransactionSchema.index({ participant: 1 }); +paymentTransactionSchema.index({ status: 1 }); +paymentTransactionSchema.index({ externalPaymentId: 1 }); + +module.exports = mongoose.model('PaymentTransaction', paymentTransactionSchema); diff --git a/src/models/PlatformPaymentConfig.js b/src/models/PlatformPaymentConfig.js new file mode 100644 index 0000000..97e3da2 --- /dev/null +++ b/src/models/PlatformPaymentConfig.js @@ -0,0 +1,18 @@ +const mongoose = require('mongoose'); + +/** Singleton platform Razorpay + default commission template (editable by super_admin). */ +const platformPaymentConfigSchema = new mongoose.Schema( + { + key: { type: String, default: 'default', unique: true, immutable: true }, + enabled: { type: Boolean, default: false }, + testMode: { type: Boolean, default: true }, + /** Encrypted JSON: { keyId, keySecret } */ + razorpayCredentialsEnc: { type: String, default: '' }, + webhookSecretEnc: { type: String, default: '' }, + /** Default platform commission for orgs on platform checkout (basis points, 100 = 1%) */ + defaultCommissionBps: { type: Number, default: 200 }, + }, + { timestamps: true } +); + +module.exports = mongoose.model('PlatformPaymentConfig', platformPaymentConfigSchema); diff --git a/src/models/Question.js b/src/models/Question.js new file mode 100644 index 0000000..91680c1 --- /dev/null +++ b/src/models/Question.js @@ -0,0 +1,46 @@ +const mongoose = require('mongoose'); + +const answerSchema = new mongoose.Schema({ + answeredBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + answer: String, + answeredAt: { type: Date, default: Date.now }, + isOfficial: { type: Boolean, default: false }, +}); + +const questionSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + session: { type: mongoose.Schema.Types.ObjectId, ref: 'Session' }, + participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }, + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + question: { type: String, required: true }, + isAnonymous: { type: Boolean, default: false }, + status: { + type: String, + enum: ['pending', 'answered', 'archived'], + default: 'pending', + }, + upvotes: { type: Number, default: 0 }, + downvotes: { type: Number, default: 0 }, + /** + * Audience votes: `key` is browser voterKey (anonymous) or participant ObjectId string (logged-in visitor). + */ + audienceVotes: [ + { + key: { type: String, required: true }, + dir: { type: String, enum: ['up', 'down'], required: true }, + }, + ], + upvotedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], + answers: [answerSchema], + }, + { timestamps: true } +); + +questionSchema.index({ event: 1 }); +questionSchema.index({ session: 1 }); +module.exports = mongoose.model('Question', questionSchema); diff --git a/src/models/QuizPoll.js b/src/models/QuizPoll.js new file mode 100644 index 0000000..bfe3dfa --- /dev/null +++ b/src/models/QuizPoll.js @@ -0,0 +1,60 @@ +const mongoose = require('mongoose'); + +const questionOptionSchema = new mongoose.Schema({ + text: String, + isCorrect: Boolean, + imageUrl: String, +}); + +const quizQuestionSchema = new mongoose.Schema({ + question: { type: String, required: true }, + imageUrl: String, + type: { + type: String, + enum: ['single', 'multiple', 'text', 'rating'], + default: 'single', + }, + options: [questionOptionSchema], + points: { type: Number, default: 1 }, + required: { type: Boolean, default: true }, +}); + +const quizPollSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + session: { type: mongoose.Schema.Types.ObjectId, ref: 'Session' }, + title: { type: String, required: true }, + type: { + type: String, + enum: ['quiz', 'poll', 'survey'], + required: true, + }, + description: String, + questions: [quizQuestionSchema], + settings: { + /** Editorial lifecycle: draft vs active (separate from live broadcast flag). */ + status: { type: String, enum: ['draft', 'active'], default: 'active' }, + /** Live / on-air for participants (publish/close toggles this). */ + isLive: { type: Boolean }, + displayMode: { type: String }, + startTime: Date, + endTime: Date, + showResults: { type: Boolean, default: true }, + allowMultipleAttempts: { type: Boolean, default: false }, + randomizeQuestions: { type: Boolean, default: false }, + timeRestricted: { type: Boolean, default: false }, + timeLimit: { type: Number, default: 0, min: 0 }, + }, + deletedAt: { type: Date, default: null }, + }, + { timestamps: true } +); + +quizPollSchema.index({ event: 1 }); +quizPollSchema.index({ session: 1 }); +quizPollSchema.index({ deletedAt: 1 }); +module.exports = mongoose.model('QuizPoll', quizPollSchema); diff --git a/src/models/QuizPollResponse.js b/src/models/QuizPollResponse.js new file mode 100644 index 0000000..280150c --- /dev/null +++ b/src/models/QuizPollResponse.js @@ -0,0 +1,31 @@ +const mongoose = require('mongoose'); + +const responseItemSchema = new mongoose.Schema({ + questionIndex: Number, + answer: mongoose.Schema.Types.Mixed, + isCorrect: Boolean, + pointsEarned: Number, +}); + +const quizPollResponseSchema = new mongoose.Schema( + { + quizPoll: { + type: mongoose.Schema.Types.ObjectId, + ref: 'QuizPoll', + required: true, + }, + participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }, + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + responses: [responseItemSchema], + totalScore: Number, + completedAt: { type: Date, default: Date.now }, + timeSpent: Number, + ipAddress: String, + userAgent: String, + }, + { timestamps: true } +); + +quizPollResponseSchema.index({ quizPoll: 1 }); +quizPollResponseSchema.index({ participant: 1 }); +module.exports = mongoose.model('QuizPollResponse', quizPollResponseSchema); diff --git a/src/models/RewardPoint.js b/src/models/RewardPoint.js new file mode 100644 index 0000000..1d6689b --- /dev/null +++ b/src/models/RewardPoint.js @@ -0,0 +1,36 @@ +const mongoose = require('mongoose'); + +const rewardPointSchema = new mongoose.Schema( + { + participant: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Participant', + required: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + points: { type: Number, required: true }, + type: { + type: String, + enum: ['earned', 'redeemed', 'adjusted'], + required: true, + }, + source: { + type: String, + enum: ['registration', 'attendance', 'feedback', 'quiz', 'referral', 'manual'], + }, + sourceId: mongoose.Schema.Types.Mixed, + description: String, + expiresAt: Date, + metadata: { type: Map, of: mongoose.Schema.Types.Mixed }, + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + }, + { timestamps: true } +); + +rewardPointSchema.index({ participant: 1, event: 1 }); +rewardPointSchema.index({ event: 1 }); +module.exports = mongoose.model('RewardPoint', rewardPointSchema); diff --git a/src/models/Session.js b/src/models/Session.js new file mode 100644 index 0000000..ec2521b --- /dev/null +++ b/src/models/Session.js @@ -0,0 +1,57 @@ +const mongoose = require('mongoose'); +const { eventBuzzCodeFor } = require('../utils/eventBuzzCode'); + +const sessionSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + track: { type: mongoose.Schema.Types.ObjectId, ref: 'Track', index: true }, + title: { type: String, required: true }, + description: String, + type: { + type: String, + enum: ['keynote', 'workshop', 'panel', 'breakout', 'networking'], + default: 'keynote', + }, + startTime: { type: Date, required: true }, + endTime: { type: Date, required: true }, + location: String, + speakers: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Speaker' }], + moderator: { type: mongoose.Schema.Types.ObjectId, ref: 'Speaker' }, + capacity: Number, + status: { + type: String, + enum: ['scheduled', 'ongoing', 'completed', 'cancelled'], + default: 'scheduled', + }, + resources: [ + { + name: String, + type: String, + url: String, + uploadedAt: { type: Date, default: Date.now }, + }, + ], + joinCode: { type: String, trim: true, uppercase: true, unique: true, sparse: true }, + }, + { timestamps: true } +); + +sessionSchema.index({ event: 1, startTime: 1 }); + +sessionSchema.pre('save', function assignSessionJoinCode(next) { + try { + const missing = !this.joinCode || !String(this.joinCode).trim(); + if (missing && this._id) { + this.joinCode = eventBuzzCodeFor(this._id.toString(), 'session'); + } + next(); + } catch (err) { + next(err); + } +}); + +module.exports = mongoose.model('Session', sessionSchema); diff --git a/src/models/SessionBookmark.js b/src/models/SessionBookmark.js new file mode 100644 index 0000000..8232699 --- /dev/null +++ b/src/models/SessionBookmark.js @@ -0,0 +1,28 @@ +const mongoose = require('mongoose'); + +const sessionBookmarkSchema = new mongoose.Schema( + { + participant: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Participant', + required: true, + index: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + index: true, + }, + session: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Session', + required: true, + }, + }, + { timestamps: true } +); + +sessionBookmarkSchema.index({ participant: 1, session: 1 }, { unique: true }); + +module.exports = mongoose.model('SessionBookmark', sessionBookmarkSchema); diff --git a/src/models/Speaker.js b/src/models/Speaker.js new file mode 100644 index 0000000..574c424 --- /dev/null +++ b/src/models/Speaker.js @@ -0,0 +1,45 @@ +const mongoose = require('mongoose'); + +const speakerSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + index: true, + }, + firstName: { type: String, required: true }, + lastName: { type: String, required: true }, + email: { type: String, required: true }, + phone: String, + title: String, + organizationName: String, + bio: String, + photo: String, + socialLinks: { + linkedin: String, + twitter: String, + website: String, + }, + expertise: [String], + sessions: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Session' }], + isFeatured: { type: Boolean, default: false }, + /** Programme role (Speaker, Keynote Speaker, Moderator, etc.) */ + speakerRole: String, + /** public | private — shown on event website when public */ + profileVisibility: { type: String, enum: ['public', 'private'], default: 'public' }, + logistics: { + honorarium: { type: Boolean, default: false }, + honorariumAmount: String, + travelRequired: { type: Boolean, default: false }, + accommodation: { type: Boolean, default: false }, + dietaryRequirements: String, + internalNotes: String, + }, + }, + { timestamps: true } +); + +speakerSchema.index({ email: 1 }); +speakerSchema.index({ organization: 1 }); +module.exports = mongoose.model('Speaker', speakerSchema); diff --git a/src/models/Track.js b/src/models/Track.js new file mode 100644 index 0000000..c2f1f80 --- /dev/null +++ b/src/models/Track.js @@ -0,0 +1,31 @@ +const mongoose = require('mongoose'); +const { eventBuzzCodeFor } = require('../utils/eventBuzzCode'); + +const trackSchema = new mongoose.Schema( + { + event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true }, + name: { type: String, required: true, trim: true }, + hall: String, + trackType: { type: String, enum: ['general', 'workshop', 'plenary', 'symposium'], default: 'general' }, + status: { type: String, enum: ['active', 'inactive'], default: 'active' }, + sortOrder: { type: Number, default: 0 }, + joinCode: { type: String, trim: true, uppercase: true, unique: true, sparse: true }, + }, + { timestamps: true } +); + +trackSchema.index({ event: 1, name: 1 }); + +trackSchema.pre('save', function assignTrackJoinCode(next) { + try { + const missing = !this.joinCode || !String(this.joinCode).trim(); + if (missing && this._id) { + this.joinCode = eventBuzzCodeFor(this._id.toString(), 'track'); + } + next(); + } catch (err) { + next(err); + } +}); + +module.exports = mongoose.model('Track', trackSchema); diff --git a/src/models/User.js b/src/models/User.js new file mode 100644 index 0000000..047841c --- /dev/null +++ b/src/models/User.js @@ -0,0 +1,71 @@ +const mongoose = require('mongoose'); +const bcrypt = require('bcrypt'); + +const notificationSettingsSchema = new mongoose.Schema( + { + emailRegistration: { type: Boolean, default: true }, + emailFeedback: { type: Boolean, default: true }, + emailPayment: { type: Boolean, default: true }, + emailSummary: { type: Boolean, default: false }, + pushRegistration: { type: Boolean, default: true }, + pushSession: { type: Boolean, default: true }, + pushMessage: { type: Boolean, default: false }, + digestFrequency: { + type: String, + enum: ['None', 'Daily', 'Weekly', 'Monthly'], + default: 'Daily', + }, + }, + { _id: false } +); + +const userSchema = new mongoose.Schema( + { + email: { type: String, required: true, unique: true }, + password: { + type: String, + required: function passwordRequired() { + return !this.googleId; + }, + }, + googleId: { type: String, unique: true, sparse: true }, + firstName: String, + lastName: String, + bio: { type: String, default: '' }, + role: { + type: String, + enum: ['super_admin', 'admin', 'moderator', 'viewer'], + default: 'viewer', + }, + organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, + profileImage: String, + phone: String, + isActive: { type: Boolean, default: true }, + lastLogin: Date, + notificationSettings: { type: notificationSettingsSchema, default: () => ({}) }, + /** Set when user saves notification preferences (onboarding / Settings). */ + notificationPreferencesSavedAt: Date, + twoFactorEnabled: { type: Boolean, default: false }, + twoFactorSecret: { type: String, select: false, default: '' }, + twoFactorTempSecret: { type: String, select: false, default: '' }, + }, + { timestamps: true } +); + +userSchema.index({ organization: 1 }); + +userSchema.pre('save', async function (next) { + if (!this.isModified('password')) return next(); + if (!this.password) { + return next(); + } + this.password = await bcrypt.hash(this.password, 10); + next(); +}); + +userSchema.methods.comparePassword = function (candidate) { + if (!this.password) return Promise.resolve(false); + return bcrypt.compare(candidate, this.password); +}; + +module.exports = mongoose.model('User', userSchema); diff --git a/src/models/VisitorAuthToken.js b/src/models/VisitorAuthToken.js new file mode 100644 index 0000000..6e48474 --- /dev/null +++ b/src/models/VisitorAuthToken.js @@ -0,0 +1,22 @@ +const mongoose = require('mongoose'); + +/** OTP and magic-link tokens; TTL on expiresAt cleans up automatically. */ +const visitorAuthTokenSchema = new mongoose.Schema( + { + kind: { type: String, enum: ['otp', 'link'], required: true }, + eventId: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true }, + email: { type: String, lowercase: true, trim: true }, + otpHash: String, + tokenHash: String, + joinType: { type: String, enum: ['event', 'session', 'track'], default: 'event' }, + sessionId: { type: mongoose.Schema.Types.ObjectId }, + trackId: { type: mongoose.Schema.Types.ObjectId }, + expiresAt: { type: Date, required: true }, + }, + { timestamps: true } +); + +visitorAuthTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); +visitorAuthTokenSchema.index({ eventId: 1, email: 1, kind: 1 }); + +module.exports = mongoose.model('VisitorAuthToken', visitorAuthTokenSchema); diff --git a/src/routes/adminRequest.routes.js b/src/routes/adminRequest.routes.js new file mode 100644 index 0000000..18cbfc2 --- /dev/null +++ b/src/routes/adminRequest.routes.js @@ -0,0 +1,19 @@ +const Joi = require('joi'); +const controller = require('../controllers/adminRequest.controller'); + +const payloadSchema = Joi.object({ + type: Joi.string().valid('data_export', 'account_deletion').required(), + message: Joi.string().allow('', null), + metadata: Joi.object().unknown(true), +}); + +module.exports = [ + { + method: 'POST', + path: '/api/admin-requests', + options: { + validate: { payload: payloadSchema }, + handler: controller.create, + }, + }, +]; diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js new file mode 100644 index 0000000..11608af --- /dev/null +++ b/src/routes/auth.routes.js @@ -0,0 +1,191 @@ +const Joi = require('joi'); +const controller = require('../controllers/auth.controller'); + +const authPayload = Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().min(6).required(), + teamName: Joi.string().min(1).required(), + firstName: Joi.string(), + lastName: Joi.string(), + organizationName: Joi.string(), +}); + +const refreshPayload = Joi.object({ + refreshToken: Joi.string().required(), +}); + +const forgotPayload = Joi.object({ + email: Joi.string().email().required(), +}); + +const resetPayload = Joi.object({ + token: Joi.string().required(), + newPassword: Joi.string().min(6).required(), +}); + +const notificationSettingsPayload = Joi.object({ + emailRegistration: Joi.boolean(), + emailFeedback: Joi.boolean(), + emailPayment: Joi.boolean(), + emailSummary: Joi.boolean(), + pushRegistration: Joi.boolean(), + pushSession: Joi.boolean(), + pushMessage: Joi.boolean(), + digestFrequency: Joi.string().valid('None', 'Daily', 'Weekly', 'Monthly'), +}); + +const updateMePayload = Joi.object({ + firstName: Joi.string().allow('', null), + lastName: Joi.string().allow('', null), + phone: Joi.string().allow('', null), + profileImage: Joi.string().allow('', null), + bio: Joi.string().allow('', null), + notificationSettings: notificationSettingsPayload, +}).min(1); + +const changePasswordPayload = Joi.object({ + currentPassword: Joi.string().required(), + newPassword: Joi.string().min(8).required(), +}); + +const tokenPayload = Joi.object({ + token: Joi.string().required(), +}); + +const disable2faPayload = Joi.object({ + currentPassword: Joi.string().required(), + token: Joi.string().allow('', null), +}); + +const login2faPayload = Joi.object({ + twoFactorToken: Joi.string().required(), + token: Joi.string().required(), +}); + +module.exports = [ + { + method: 'POST', + path: '/api/auth/register', + options: { + auth: false, + validate: { payload: authPayload }, + handler: controller.register, + }, + }, + { + method: 'POST', + path: '/api/auth/login', + options: { + auth: false, + validate: { + payload: Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().required(), + }), + }, + handler: controller.login, + }, + }, + { + method: 'POST', + path: '/api/auth/google', + options: { + auth: false, + validate: { + payload: Joi.object({ + idToken: Joi.string().required(), + }), + }, + handler: controller.googleLogin, + }, + }, + { + method: 'POST', + path: '/api/auth/google/register', + options: { + auth: false, + validate: { + payload: Joi.object({ + idToken: Joi.string().required(), + teamName: Joi.string().min(1).required(), + firstName: Joi.string().allow('', null), + lastName: Joi.string().allow('', null), + phone: Joi.string().allow('', null), + organizationName: Joi.string(), + }), + }, + handler: controller.googleRegister, + }, + }, + { + method: 'POST', + path: '/api/auth/login/2fa', + options: { + auth: false, + validate: { payload: login2faPayload }, + handler: controller.login2fa, + }, + }, + { + method: 'POST', + path: '/api/auth/refresh', + options: { + auth: false, + validate: { payload: refreshPayload }, + handler: controller.refresh, + }, + }, + { + method: 'POST', + path: '/api/auth/logout', + options: { handler: controller.logout }, + }, + { + method: 'POST', + path: '/api/auth/forgot-password', + options: { + auth: false, + validate: { payload: forgotPayload }, + handler: controller.forgotPassword, + }, + }, + { + method: 'POST', + path: '/api/auth/reset-password', + options: { + auth: false, + validate: { payload: resetPayload }, + handler: controller.resetPassword, + }, + }, + { + method: 'GET', + path: '/api/auth/me', + options: { handler: controller.me }, + }, + { + method: 'PUT', + path: '/api/auth/me', + options: { validate: { payload: updateMePayload }, handler: controller.updateMe }, + }, + { + method: 'POST', + path: '/api/auth/change-password', + options: { validate: { payload: changePasswordPayload }, handler: controller.changePassword }, + }, + { + method: 'POST', + path: '/api/auth/2fa/setup', + options: { handler: controller.setup2fa }, + }, + { + method: 'POST', + path: '/api/auth/2fa/verify-setup', + options: { validate: { payload: tokenPayload }, handler: controller.verify2faSetup }, + }, + { + method: 'POST', + path: '/api/auth/2fa/disable', + options: { validate: { payload: disable2faPayload }, handler: controller.disable2fa }, + }, +]; diff --git a/src/routes/campaign.routes.js b/src/routes/campaign.routes.js new file mode 100644 index 0000000..bf7da4b --- /dev/null +++ b/src/routes/campaign.routes.js @@ -0,0 +1,40 @@ +const Joi = require('joi'); +const controller = require('../controllers/campaign.controller'); +const { CAMPAIGN_CHANNELS, CAMPAIGN_TYPES } = require('../config/constants'); + +const campaignPayload = Joi.object({ + name: Joi.string().required(), + channel: Joi.string().valid(...CAMPAIGN_CHANNELS).required(), + type: Joi.string().valid(...CAMPAIGN_TYPES).required(), + triggerEvent: Joi.string(), + recipients: Joi.object({ + type: Joi.string().valid('all', 'filtered', 'custom'), + filters: Joi.object(), + customList: Joi.array().items(Joi.string()), + }), + content: Joi.object({ + subject: Joi.string(), + body: Joi.string(), + template: Joi.string(), + variables: Joi.object(), + }), +}); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/campaigns', options: { handler: controller.list } }, + { method: 'POST', path: '/api/events/{eventId}/campaigns', options: { validate: { payload: campaignPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/campaigns/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/campaigns/{id}', options: { validate: { payload: campaignPayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/campaigns/{id}', options: { handler: controller.remove } }, + { method: 'POST', path: '/api/campaigns/{id}/send', options: { handler: controller.send } }, + { + method: 'POST', + path: '/api/campaigns/{id}/schedule', + options: { + validate: { payload: Joi.object({ scheduledFor: Joi.date().iso().required() }) }, + handler: controller.schedule, + }, + }, + { method: 'POST', path: '/api/campaigns/{id}/cancel', options: { handler: controller.cancel } }, + { method: 'GET', path: '/api/campaigns/{id}/analytics', options: { handler: controller.analytics } }, +]; diff --git a/src/routes/contentCollection.routes.js b/src/routes/contentCollection.routes.js new file mode 100644 index 0000000..ae4af9b --- /dev/null +++ b/src/routes/contentCollection.routes.js @@ -0,0 +1,52 @@ +const Joi = require('joi'); +const controller = require('../controllers/contentCollection.controller'); + +const createPayload = Joi.object({ + name: Joi.string().required(), + description: Joi.string().allow('', null), + collectionType: Joi.string().allow('', null), + coverIcon: Joi.string().allow('', null), + tags: Joi.array().items(Joi.string()), + contentIds: Joi.array().items(Joi.string()).min(1).required(), + visibility: Joi.string().valid('private', 'shared', 'public').required(), + sharedWith: Joi.array().items(Joi.string()), + passwordProtected: Joi.boolean(), + password: Joi.string().allow('', null), + expiryEnabled: Joi.boolean(), + expiryDate: Joi.alternatives().try(Joi.date(), Joi.string().allow('', null), Joi.valid(null)), + notifyOnAdd: Joi.boolean(), + notifyOnShare: Joi.boolean(), +}); + +const updatePayload = Joi.object({ + name: Joi.string(), + description: Joi.string().allow('', null), + collectionType: Joi.string().allow('', null), + coverIcon: Joi.string().allow('', null), + tags: Joi.array().items(Joi.string()), + contentIds: Joi.array().items(Joi.string()), + visibility: Joi.string().valid('private', 'shared', 'public'), + sharedWith: Joi.array().items(Joi.string()), + passwordProtected: Joi.boolean(), + password: Joi.string().allow('', null), + expiryEnabled: Joi.boolean(), + expiryDate: Joi.alternatives().try(Joi.date(), Joi.string().allow('', null), Joi.valid(null)), + notifyOnAdd: Joi.boolean(), + notifyOnShare: Joi.boolean(), +}).min(1); + +module.exports = [ + { method: 'GET', path: '/api/content-collections', options: { handler: controller.list } }, + { + method: 'POST', + path: '/api/content-collections', + options: { validate: { payload: createPayload }, handler: controller.create }, + }, + { method: 'GET', path: '/api/content-collections/{id}', options: { handler: controller.get } }, + { + method: 'PUT', + path: '/api/content-collections/{id}', + options: { validate: { payload: updatePayload }, handler: controller.update }, + }, + { method: 'DELETE', path: '/api/content-collections/{id}', options: { handler: controller.remove } }, +]; diff --git a/src/routes/designTemplate.routes.js b/src/routes/designTemplate.routes.js new file mode 100644 index 0000000..86e0c65 --- /dev/null +++ b/src/routes/designTemplate.routes.js @@ -0,0 +1,35 @@ +const Joi = require('joi'); +const controller = require('../controllers/designTemplate.controller'); +const { DESIGN_TEMPLATE_TYPES } = require('../config/constants'); + +const variableSchema = Joi.object({ + name: Joi.string(), + type: Joi.string(), + defaultValue: Joi.string(), + required: Joi.boolean(), +}); + +const templatePayload = Joi.object({ + name: Joi.string().required(), + type: Joi.string().valid(...DESIGN_TEMPLATE_TYPES).required(), + category: Joi.string(), + thumbnail: Joi.string(), + content: Joi.any(), + variables: Joi.array().items(variableSchema), + organization: Joi.string(), + settings: Joi.object({ + isPublic: Joi.boolean(), + isActive: Joi.boolean(), + canDuplicate: Joi.boolean(), + }), +}); + +module.exports = [ + { method: 'GET', path: '/api/design-templates', options: { handler: controller.list } }, + { method: 'POST', path: '/api/design-templates', options: { validate: { payload: templatePayload }, handler: controller.create } }, + { method: 'GET', path: '/api/design-templates/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/design-templates/{id}', options: { validate: { payload: templatePayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/design-templates/{id}', options: { handler: controller.remove } }, + { method: 'POST', path: '/api/design-templates/{id}/duplicate', options: { handler: controller.duplicate } }, + { method: 'POST', path: '/api/design-templates/{id}/use/{eventId}', options: { handler: controller.useForEvent } }, +]; diff --git a/src/routes/event.routes.js b/src/routes/event.routes.js new file mode 100644 index 0000000..f1f6b33 --- /dev/null +++ b/src/routes/event.routes.js @@ -0,0 +1,79 @@ +const Joi = require('joi'); +const controller = require('../controllers/event.controller'); +const { EVENT_TYPES, EVENT_STATUS } = require('../config/constants'); + +const AUTHENTICATION_TYPES = ['otp_email', 'link_email', 'none']; +const REGISTRATION_BASE_FIELDS = [ + 'name_email', + 'email_only', + 'name_email_phone', + 'anonymous_name', + 'anonymous', +]; + +const fieldOptionSchema = Joi.object({ + value: Joi.string().allow('', null), + label: Joi.string().allow('', null), +}); + +const registrationFieldSchema = Joi.object({ + label: Joi.string().allow('', null), + code: Joi.string().allow('', null), + fieldType: Joi.string().allow('', null), + options: Joi.array().items(fieldOptionSchema), +}); + +const eventPayload = Joi.object({ + name: Joi.string().min(3).max(100).required(), + subtitle: Joi.string().allow('', null), + description: Joi.string().allow('', null), + type: Joi.string().valid(...EVENT_TYPES).required(), + status: Joi.string().valid(...EVENT_STATUS), + category: Joi.string().allow('', null), + startDate: Joi.date().iso().required(), + endDate: Joi.date().iso().required(), + location: Joi.string().allow('', null), + locationType: Joi.string().valid('physical', 'virtual', 'hybrid'), + virtualLink: Joi.string().allow('', null), + tags: Joi.array().items(Joi.string()), + image: Joi.string().allow('', null), + bannerImage: Joi.string().allow('', null), + organization: Joi.string(), + settings: Joi.object({ + registrationOpen: Joi.boolean(), + maxParticipants: Joi.number(), + requiresApproval: Joi.boolean(), + registrationMode: Joi.string().allow('', null), + verifyEmail: Joi.boolean(), + registrationDeadline: Joi.date().iso().allow(null), + enableFeedback: Joi.boolean(), + enableRatings: Joi.boolean(), + authenticationType: Joi.string().valid(...AUTHENTICATION_TYPES).allow('', null), + registrationBaseFields: Joi.string().valid(...REGISTRATION_BASE_FIELDS).allow('', null), + inviteCode: Joi.string().allow('', null), + }), + metadata: Joi.object({ + registrationCustomFields: Joi.array().items(registrationFieldSchema), + }), +}); + +module.exports = [ + { method: 'GET', path: '/api/events', options: { handler: controller.list } }, + { method: 'POST', path: '/api/events', options: { validate: { payload: eventPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/events/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/events/{id}', options: { validate: { payload: eventPayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/events/{id}', options: { handler: controller.remove } }, + { method: 'GET', path: '/api/events/{id}/analytics', options: { handler: controller.analytics } }, + { + method: 'GET', + path: '/api/events/{id}/registration-link', + options: { + validate: { + params: Joi.object({ id: Joi.string().required() }), + query: Joi.object({ formId: Joi.string().required() }), + }, + handler: controller.registrationLink, + }, + }, + { method: 'POST', path: '/api/events/{id}/duplicate', options: { handler: controller.duplicate } }, +]; diff --git a/src/routes/eventMicrosite.routes.js b/src/routes/eventMicrosite.routes.js new file mode 100644 index 0000000..2610a1b --- /dev/null +++ b/src/routes/eventMicrosite.routes.js @@ -0,0 +1,143 @@ +const Joi = require('joi'); +const controller = require('../controllers/eventMicrosite.controller'); +const { WEBSITE_SECTION_TYPES } = require('../config/constants'); + +const mongoId = Joi.string().length(24).hex().required(); +const optionalMongoId = Joi.string().length(24).hex().allow('', null); + +const micrositeBody = { + name: Joi.string().allow('', null), + publishFolder: Joi.string().allow('', null), + enabled: Joi.boolean(), + logo: Joi.string().allow('', null), + banners: Joi.array().items(Joi.string()), + favicon: Joi.string().allow('', null), + title: Joi.string().allow('', null), + description: Joi.string().allow('', null), + keywords: Joi.array().items(Joi.string()), + templateId: Joi.string().allow('', null), + layoutTemplate: Joi.string().valid('conference'), + seoDescription: Joi.string().allow('', null), + micrositeSections: Joi.array().items(Joi.string().valid(...WEBSITE_SECTION_TYPES)), + primaryColor: Joi.string().max(32).allow('', null), + accentColor: Joi.string().max(32).allow('', null), + fontPair: Joi.string().allow('', null), + registrationUrl: Joi.string().max(2048).allow('', null), + registrationFormId: optionalMongoId, + micrositeHtml: Joi.string().allow('').max(2000000), + sourcePageId: optionalMongoId, +}; + +const createPayload = Joi.object({ + name: micrositeBody.name, + publishFolder: Joi.string().required(), + enabled: micrositeBody.enabled, + logo: micrositeBody.logo, + banners: micrositeBody.banners, + favicon: micrositeBody.favicon, + title: micrositeBody.title, + description: micrositeBody.description, + keywords: micrositeBody.keywords, + templateId: micrositeBody.templateId, + layoutTemplate: micrositeBody.layoutTemplate, + seoDescription: micrositeBody.seoDescription, + micrositeSections: micrositeBody.micrositeSections, + primaryColor: micrositeBody.primaryColor, + accentColor: micrositeBody.accentColor, + fontPair: micrositeBody.fontPair, + registrationUrl: micrositeBody.registrationUrl, + registrationFormId: micrositeBody.registrationFormId, + micrositeHtml: micrositeBody.micrositeHtml, + sourcePageId: micrositeBody.sourcePageId, +}); + +const updatePayload = Joi.object(micrositeBody).min(1); + +const publishPayload = Joi.object({ + targets: Joi.string().valid('draft', 'live', 'both'), +}); + +module.exports = [ + { + method: 'GET', + path: '/api/event-microsites', + options: { + validate: { + query: Joi.object({ + organization: Joi.string().length(24).hex().required(), + }), + }, + handler: controller.listForOrganization, + }, + }, + { + method: 'GET', + path: '/api/events/{eventId}/microsites/check-folder', + options: { + validate: { + params: Joi.object({ eventId: mongoId }), + query: Joi.object({ + folder: Joi.string().required(), + micrositeId: Joi.string().length(24).hex().allow('', null), + }), + }, + handler: controller.checkFolder, + }, + }, + { + method: 'GET', + path: '/api/events/{eventId}/microsites', + options: { validate: { params: Joi.object({ eventId: mongoId }) }, handler: controller.list }, + }, + { + method: 'POST', + path: '/api/events/{eventId}/microsites', + options: { + validate: { params: Joi.object({ eventId: mongoId }), payload: createPayload }, + handler: controller.create, + }, + }, + { + method: 'GET', + path: '/api/events/{eventId}/microsites/{micrositeId}', + options: { + validate: { params: Joi.object({ eventId: mongoId, micrositeId: mongoId }) }, + handler: controller.getById, + }, + }, + { + method: 'PUT', + path: '/api/events/{eventId}/microsites/{micrositeId}', + options: { + validate: { params: Joi.object({ eventId: mongoId, micrositeId: mongoId }), payload: updatePayload }, + handler: controller.update, + }, + }, + { + method: 'DELETE', + path: '/api/events/{eventId}/microsites/{micrositeId}', + options: { + validate: { params: Joi.object({ eventId: mongoId, micrositeId: mongoId }) }, + handler: controller.remove, + }, + }, + { + method: 'POST', + path: '/api/events/{eventId}/microsites/{micrositeId}/publish', + options: { + validate: { + params: Joi.object({ eventId: mongoId, micrositeId: mongoId }), + payload: publishPayload, + }, + handler: controller.publish, + }, + }, + { + method: 'GET', + path: '/api/events/{eventId}/microsites/{micrositeId}/preview-html', + options: { + validate: { params: Joi.object({ eventId: mongoId, micrositeId: mongoId }) }, + handler: controller.previewHtml, + }, + }, +]; diff --git a/src/routes/eventStaff.routes.js b/src/routes/eventStaff.routes.js new file mode 100644 index 0000000..103c48f --- /dev/null +++ b/src/routes/eventStaff.routes.js @@ -0,0 +1,67 @@ +const Joi = require('joi'); +const controller = require('../controllers/eventStaff.controller'); + +const addStaffPayload = Joi.object({ + userIds: Joi.array().items(Joi.string()).min(1).required(), + sessionIds: Joi.array().items(Joi.string()), + eventRole: Joi.string().allow('', null), +}); + +const sendInvitesPayload = Joi.object({ + invites: Joi.array() + .items( + Joi.object({ + email: Joi.string().email().required(), + eventRole: Joi.string().allow('', null), + }) + ) + .min(1) + .required(), + sessionIds: Joi.array().items(Joi.string()), +}); + +const updateStaffPayload = Joi.object({ + sessionIds: Joi.array().items(Joi.string()), + eventRole: Joi.string().allow('', null), +}); + +const acceptPayload = Joi.object({ + token: Joi.string().required(), + password: Joi.string().required(), + firstName: Joi.string().allow('', null), + lastName: Joi.string().allow('', null), +}); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/staff', options: { handler: controller.listStaff } }, + { method: 'GET', path: '/api/events/{eventId}/staff/available-users', options: { handler: controller.listAvailableUsers } }, + { method: 'GET', path: '/api/events/{eventId}/agenda-tree', options: { handler: controller.agendaTree } }, + { + method: 'POST', + path: '/api/events/{eventId}/staff/invites', + options: { validate: { payload: sendInvitesPayload }, handler: controller.sendInvites }, + }, + { method: 'DELETE', path: '/api/events/{eventId}/staff/invites/{inviteId}', options: { handler: controller.cancelInvite } }, + { method: 'POST', path: '/api/events/{eventId}/staff/invites/{inviteId}/resend', options: { handler: controller.resendInvite } }, + { + method: 'POST', + path: '/api/events/{eventId}/staff', + options: { validate: { payload: addStaffPayload }, handler: controller.addStaff }, + }, + { + method: 'PUT', + path: '/api/events/{eventId}/staff/{userId}', + options: { validate: { payload: updateStaffPayload }, handler: controller.updateStaff }, + }, + { method: 'DELETE', path: '/api/events/{eventId}/staff/{userId}', options: { handler: controller.removeStaff } }, + { + method: 'GET', + path: '/api/event-invites/{token}', + options: { auth: false, handler: controller.invitePreview }, + }, + { + method: 'POST', + path: '/api/event-invites/accept', + options: { auth: false, validate: { payload: acceptPayload }, handler: controller.inviteAccept }, + }, +]; diff --git a/src/routes/form.routes.js b/src/routes/form.routes.js new file mode 100644 index 0000000..6ac8ae5 --- /dev/null +++ b/src/routes/form.routes.js @@ -0,0 +1,78 @@ +const Joi = require('joi'); +const controller = require('../controllers/form.controller'); +const { FORM_TYPES, FIELD_TYPES, FORM_FIELD_MAPS_TO } = require('../config/constants'); + +const fieldSchema = Joi.object({ + label: Joi.string(), + type: Joi.string().valid(...FIELD_TYPES), + required: Joi.boolean(), + options: Joi.array().items(Joi.string()), + placeholder: Joi.string(), + mapsTo: Joi.string().valid(...FORM_FIELD_MAPS_TO).allow('', null), + customMapsKey: Joi.string().allow('', null), + isFixed: Joi.boolean(), +}); + +const settingsSchema = Joi.object({ + isActive: Joi.boolean(), + allowMultiple: Joi.boolean(), + submissionLimit: Joi.number(), + thankYouMessage: Joi.string().allow('', null), + responseDeadline: Joi.alternatives().try(Joi.date(), Joi.string().allow('', null), Joi.valid(null)), + showThankYou: Joi.boolean(), + collectEmail: Joi.boolean(), + allowAnonymous: Joi.boolean(), + paymentEnabled: Joi.boolean(), + paymentAmount: Joi.alternatives().try(Joi.string(), Joi.number()).allow('', null), + sendConfirmationEmail: Joi.boolean(), + onSubmitAddTags: Joi.array().items(Joi.string()), + onSubmitParticipantListId: Joi.string().allow(null, ''), +}); + +const formPayload = Joi.object({ + name: Joi.string().required(), + type: Joi.string().valid(...FORM_TYPES).required(), + description: Joi.string().allow('', null), + fields: Joi.array().items(fieldSchema), + settings: settingsSchema, +}); + +/** tenantOrganization: super_admin may set explicitly; others get it from JWT in the controller. */ +const formOrgCreatePayload = Joi.object({ + name: Joi.string().required(), + type: Joi.string().valid(...FORM_TYPES).required(), + description: Joi.string().allow('', null), + fields: Joi.array().items(fieldSchema), + settings: settingsSchema, + event: Joi.string().allow(null, ''), + tenantOrganization: Joi.alternatives().try( + Joi.string().hex().length(24), + Joi.string().allow(null, ''), + Joi.valid(null) + ), +}); + +const submitPayload = Joi.object({ + participantId: Joi.string(), + data: Joi.object().pattern(Joi.string(), Joi.any()), +}); + +module.exports = [ + { method: 'GET', path: '/api/forms', options: { handler: controller.listByOrganization } }, + { + method: 'POST', + path: '/api/forms', + options: { validate: { payload: formOrgCreatePayload }, handler: controller.createByOrganization }, + }, + { method: 'GET', path: '/api/events/{eventId}/forms', options: { handler: controller.list } }, + { method: 'POST', path: '/api/events/{eventId}/forms', options: { validate: { payload: formPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/forms/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/forms/{id}', options: { validate: { payload: formPayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/forms/{id}', options: { handler: controller.remove } }, + { + method: 'POST', + path: '/api/forms/{formId}/submit', + options: { auth: false, validate: { payload: submitPayload }, handler: controller.submit }, + }, + { method: 'GET', path: '/api/forms/{formId}/submissions', options: { handler: controller.listSubmissions } }, +]; diff --git a/src/routes/media.routes.js b/src/routes/media.routes.js new file mode 100644 index 0000000..a282e97 --- /dev/null +++ b/src/routes/media.routes.js @@ -0,0 +1,23 @@ +const controller = require('../controllers/media.controller'); + +module.exports = [ + { method: 'GET', path: '/api/media', options: { handler: controller.list } }, + { method: 'GET', path: '/api/library/media', options: { handler: controller.listLibrary } }, + { + method: 'POST', + path: '/api/media/upload', + options: { + payload: { + maxBytes: 50 * 1024 * 1024, + // Hapi only allows output: data | stream | file. Binary multipart parts arrive as raw Buffer on payload.file. + output: 'data', + parse: true, + multipart: true, + allow: ['multipart/form-data', 'application/json'], + }, + handler: controller.upload, + }, + }, + { method: 'GET', path: '/api/media/{id}', options: { handler: controller.get } }, + { method: 'DELETE', path: '/api/media/{id}', options: { handler: controller.remove } }, +]; diff --git a/src/routes/notification.routes.js b/src/routes/notification.routes.js new file mode 100644 index 0000000..3e49f52 --- /dev/null +++ b/src/routes/notification.routes.js @@ -0,0 +1,9 @@ +const controller = require('../controllers/notification.controller'); + +module.exports = [ + { method: 'GET', path: '/api/notifications', options: { handler: controller.list } }, + { method: 'GET', path: '/api/notifications/unread', options: { handler: controller.unread } }, + { method: 'PUT', path: '/api/notifications/{id}/read', options: { handler: controller.markRead } }, + { method: 'PUT', path: '/api/notifications/read-all', options: { handler: controller.markAllRead } }, + { method: 'DELETE', path: '/api/notifications/{id}', options: { handler: controller.remove } }, +]; diff --git a/src/routes/organization.routes.js b/src/routes/organization.routes.js new file mode 100644 index 0000000..9faec58 --- /dev/null +++ b/src/routes/organization.routes.js @@ -0,0 +1,43 @@ +const Joi = require('joi'); +const controller = require('../controllers/organization.controller'); +const { ROLES } = require('../config/constants'); + +const orgPayload = Joi.object({ + name: Joi.string(), + description: Joi.string(), + email: Joi.string().email(), + phone: Joi.string(), + website: Joi.string(), + address: Joi.string(), + logo: Joi.string(), + timezone: Joi.string(), + dateFormat: Joi.string(), + language: Joi.string(), + subscription: Joi.object({ + plan: Joi.string().valid('free', 'pro', 'enterprise'), + status: Joi.string().valid('active', 'trial', 'past_due', 'cancelled'), + expiresAt: Joi.date().allow(null), + maxEvents: Joi.number().allow(null), + maxParticipants: Joi.number().allow(null), + featureOverrides: Joi.array().items(Joi.string()), + }), +}); + +const userPayload = Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().min(6), + firstName: Joi.string(), + lastName: Joi.string(), + role: Joi.string().valid(...ROLES), + phone: Joi.string(), + assignedEvents: Joi.array().items(Joi.string()).optional(), +}); + +module.exports = [ + { method: 'GET', path: '/api/organization', options: { handler: controller.getOrganization } }, + { method: 'PUT', path: '/api/organization', options: { validate: { payload: orgPayload }, handler: controller.updateOrganization } }, + { method: 'GET', path: '/api/users', options: { handler: controller.listUsers } }, + { method: 'POST', path: '/api/users', options: { validate: { payload: userPayload }, handler: controller.addUser } }, + { method: 'PUT', path: '/api/users/{id}', options: { validate: { payload: userPayload }, handler: controller.updateUser } }, + { method: 'DELETE', path: '/api/users/{id}', options: { handler: controller.removeUser } }, +]; diff --git a/src/routes/page.routes.js b/src/routes/page.routes.js new file mode 100644 index 0000000..f3c5e83 --- /dev/null +++ b/src/routes/page.routes.js @@ -0,0 +1,65 @@ +const Joi = require('joi'); +const controller = require('../controllers/page.controller'); +const { PAGE_TYPES } = require('../config/constants'); + +const mongoId = Joi.string().length(24).hex().required(); + +const seoSchema = Joi.object({ + title: Joi.string(), + description: Joi.string(), + keywords: Joi.array().items(Joi.string()), + image: Joi.string(), +}); + +const sectionSchema = Joi.object({ + id: Joi.string().required(), + type: Joi.string().required(), + order: Joi.number(), + config: Joi.any(), +}); + +const pagePayload = Joi.object({ + title: Joi.string().required(), + slug: Joi.string(), + type: Joi.string().valid(...PAGE_TYPES).required(), + sections: Joi.array().items(sectionSchema), + content: Joi.any(), + seo: seoSchema, + settings: Joi.object({ + isPublished: Joi.boolean(), + showInMenu: Joi.boolean(), + menuOrder: Joi.number(), + requireLogin: Joi.boolean(), + }), +}); + +const pageUpdatePayload = Joi.object({ + title: Joi.string(), + slug: Joi.string(), + type: Joi.string().valid(...PAGE_TYPES), + sections: Joi.array().items(sectionSchema), + micrositeHtml: Joi.string().allow('').max(2000000), + content: Joi.any(), + seo: seoSchema, + settings: Joi.object({ + isPublished: Joi.boolean(), + showInMenu: Joi.boolean(), + menuOrder: Joi.number(), + requireLogin: Joi.boolean(), + }), +}).min(1); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/pages', options: { validate: { params: Joi.object({ eventId: mongoId }) }, handler: controller.list } }, + { method: 'POST', path: '/api/events/{eventId}/pages', options: { validate: { params: Joi.object({ eventId: mongoId }), payload: pagePayload }, handler: controller.create } }, + { method: 'GET', path: '/api/pages/{id}', options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.get } }, + { method: 'PUT', path: '/api/pages/{id}', options: { validate: { params: Joi.object({ id: mongoId }), payload: pageUpdatePayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/pages/{id}', options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.remove } }, + { method: 'POST', path: '/api/pages/{id}/publish', options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.publish } }, + { + method: 'GET', + path: '/api/pages/public/{eventSlug}/{pageSlug}', + options: { auth: false, handler: controller.publicPage }, + }, + { method: 'GET', path: '/api/pages/{id}/export-html', options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.exportPageHtml } }, +]; diff --git a/src/routes/participant-list.routes.js b/src/routes/participant-list.routes.js new file mode 100644 index 0000000..7ab9660 --- /dev/null +++ b/src/routes/participant-list.routes.js @@ -0,0 +1,46 @@ +const Joi = require('joi'); +const controller = require('../controllers/participantList.controller'); + +const listCreatePayload = Joi.object({ + name: Joi.string().required(), + description: Joi.string().allow(''), + visibility: Joi.string().valid('private', 'shared'), + type: Joi.string().valid('fixed', 'rule_based').required(), + autoUpdate: Joi.boolean(), + rules: Joi.object({ + rules: Joi.array().items(Joi.object()), + conjunction: Joi.string().valid('AND', 'OR'), + }).allow(null), + tenantOrganization: Joi.string(), // super_admin only + participantIds: Joi.array().items(Joi.string()), +}); + +const listUpdatePayload = Joi.object({ + name: Joi.string(), + description: Joi.string().allow(''), + visibility: Joi.string().valid('private', 'shared'), + type: Joi.string().valid('fixed', 'rule_based'), + autoUpdate: Joi.boolean(), + rules: Joi.object({ + rules: Joi.array().items(Joi.object()), + conjunction: Joi.string().valid('AND', 'OR'), + }).allow(null), + participantIds: Joi.array().items(Joi.string()), +}).min(1); + +module.exports = [ + { method: 'GET', path: '/api/participant-lists', options: { handler: controller.list } }, + { + method: 'POST', + path: '/api/participant-lists', + options: { validate: { payload: listCreatePayload }, handler: controller.create }, + }, + { method: 'GET', path: '/api/participant-lists/{id}', options: { handler: controller.get } }, + { + method: 'PUT', + path: '/api/participant-lists/{id}', + options: { validate: { payload: listUpdatePayload }, handler: controller.update }, + }, + { method: 'DELETE', path: '/api/participant-lists/{id}', options: { handler: controller.remove } }, + { method: 'GET', path: '/api/participant-lists/{id}/members', options: { handler: controller.listMembers } }, +]; diff --git a/src/routes/participant.routes.js b/src/routes/participant.routes.js new file mode 100644 index 0000000..b63ade5 --- /dev/null +++ b/src/routes/participant.routes.js @@ -0,0 +1,76 @@ +const Joi = require('joi'); +const controller = require('../controllers/participant.controller'); + +const participantPayload = Joi.object({ + email: Joi.string().email().required(), + firstName: Joi.string().required(), + lastName: Joi.string().required(), + phone: Joi.string(), + organization: Joi.string(), + designation: Joi.string(), + tags: Joi.array().items(Joi.string()), + notes: Joi.string(), +}); + +const participantUpdatePayload = Joi.object({ + email: Joi.string().email(), + firstName: Joi.string().allow(''), + lastName: Joi.string().allow(''), + phone: Joi.string().allow('', null), + organization: Joi.string().allow('', null), + designation: Joi.string().allow('', null), + tags: Joi.array().items(Joi.string()), + notes: Joi.string().allow('', null), +}).min(1); + +const bulkPayload = Joi.object({ + action: Joi.string() + .valid('add_tags', 'remove_tags', 'assign_events', 'add_to_fixed_list', 'delete') + .required(), + participantIds: Joi.array().items(Joi.string()).default([]), + selectAllMatching: Joi.boolean().default(false), + q: Joi.string().allow(''), + tag: Joi.string().allow(''), + tags: Joi.array().items(Joi.string()), + eventIds: Joi.array().items(Joi.string()), + listId: Joi.string(), + /** Tenant scope for select-all / super_admin (also accepts ?organization= on the request). */ + organization: Joi.string(), +}); + +const importPayload = Joi.object({ + csv: Joi.string().required().max(8 * 1024 * 1024), +}); + +const globalImportPayload = Joi.object({ + csv: Joi.string().required().max(8 * 1024 * 1024), + eventIds: Joi.array().items(Joi.string()).default([]), + createCustomList: Joi.boolean().default(false), + listName: Joi.string().allow(''), + listDescription: Joi.string().allow(''), +}); + +module.exports = [ + { method: 'GET', path: '/api/participants/export', options: { handler: controller.exportAllCsv } }, + { + method: 'POST', + path: '/api/participants/import', + options: { validate: { payload: globalImportPayload }, handler: controller.importGlobalCsv }, + }, + { method: 'GET', path: '/api/events/{eventId}/participants/export', options: { handler: controller.exportCsv } }, + { method: 'POST', path: '/api/events/{eventId}/participants/import', options: { validate: { payload: importPayload }, handler: controller.importCsv } }, + { method: 'GET', path: '/api/events/{eventId}/participants', options: { handler: controller.list } }, + { method: 'POST', path: '/api/events/{eventId}/participants', options: { validate: { payload: participantPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/participants/tags', options: { handler: controller.listDistinctTags } }, + { method: 'GET', path: '/api/participants/matching-ids', options: { handler: controller.listMatchingIds } }, + { + method: 'POST', + path: '/api/participants/bulk', + options: { validate: { payload: bulkPayload }, handler: controller.bulkParticipants }, + }, + { method: 'GET', path: '/api/participants', options: { handler: controller.listAll } }, + { method: 'GET', path: '/api/participants/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/participants/{id}', options: { validate: { payload: participantUpdatePayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/participants/{id}', options: { handler: controller.remove } }, + { method: 'POST', path: '/api/participants/{id}/check-in', options: { handler: controller.checkIn } }, +]; diff --git a/src/routes/payment.routes.js b/src/routes/payment.routes.js new file mode 100644 index 0000000..ba1d68a --- /dev/null +++ b/src/routes/payment.routes.js @@ -0,0 +1,184 @@ +const Joi = require('joi'); +const payment = require('../controllers/payment.controller'); +const platform = require('../controllers/platformPayment.controller'); +const profile = require('../controllers/paymentProfile.controller'); + +const platformUpdatePayload = Joi.object({ + enabled: Joi.boolean(), + testMode: Joi.boolean(), + defaultCommissionBps: Joi.number().min(0).max(10000), + razorpayKeyId: Joi.string().allow('', null), + razorpayKeySecret: Joi.string().allow('', null), + webhookSecret: Joi.string().allow('', null), +}).min(1); + +const orgProfilePayload = Joi.object({ + checkoutMode: Joi.string().valid('platform_razorpay', 'custom'), + platformCommissionBps: Joi.number().min(0).max(10000).allow(null), + defaultCurrency: Joi.string(), + taxRate: Joi.number().allow(null), + taxType: Joi.string().allow('', null), +}).min(1); + +const addGatewayPayload = Joi.object({ + provider: Joi.string() + .valid('razorpay', 'payu', 'ccavenue', 'cashfree', 'stripe', 'paytm') + .required(), + label: Joi.string().allow('', null), + credentials: Joi.object().default({}), + isPrimary: Joi.boolean(), + testMode: Joi.boolean(), + isActive: Joi.boolean(), +}); + +const patchGatewayPayload = Joi.object({ + label: Joi.string().allow('', null), + isActive: Joi.boolean(), + testMode: Joi.boolean(), + isPrimary: Joi.boolean(), + credentials: Joi.object(), +}).min(1); + +const orderPayload = Joi.object({ + amountSubtotal: Joi.number().integer().positive().required(), + currency: Joi.string().length(3).uppercase(), + eventId: Joi.string().allow('', null), + participantId: Joi.string().allow('', null), + idempotencyKey: Joi.string().max(200).allow('', null), + formId: Joi.string().allow('', null), +}); + +const verifyPayload = Joi.object({ + orderId: Joi.string().required(), + paymentId: Joi.string().required(), + signature: Joi.string().required(), +}); + +const publicOrderPayload = Joi.alternatives().try( + Joi.object({ + amountSubtotal: Joi.number().integer().positive().required(), + currency: Joi.string().length(3).uppercase(), + participantId: Joi.string().required(), + idempotencyKey: Joi.string().max(200).allow('', null), + }), + Joi.object({ + amountSubtotal: Joi.number().integer().positive().required(), + currency: Joi.string().length(3).uppercase(), + guestToken: Joi.string().required(), + email: Joi.string().email().required(), + idempotencyKey: Joi.string().max(200).allow('', null), + }), +); + +module.exports = [ + { method: 'GET', path: '/api/platform/payment-config', options: { handler: platform.getPlatformPaymentConfig } }, + { + method: 'PUT', + path: '/api/platform/payment-config', + options: { validate: { payload: platformUpdatePayload }, handler: platform.updatePlatformPaymentConfig }, + }, + { method: 'GET', path: '/api/organization/payment-profile', options: { handler: profile.getOrgPaymentProfile } }, + { + method: 'PUT', + path: '/api/organization/payment-profile', + options: { validate: { payload: orgProfilePayload }, handler: profile.updateOrgPaymentProfile }, + }, + { + method: 'POST', + path: '/api/organization/payment-profile/gateways', + options: { validate: { payload: addGatewayPayload }, handler: profile.addGateway }, + }, + { + method: 'PATCH', + path: '/api/organization/payment-profile/gateways/{id}', + options: { validate: { payload: patchGatewayPayload, params: Joi.object({ id: Joi.string().required() }) }, handler: profile.patchGateway }, + }, + { + method: 'DELETE', + path: '/api/organization/payment-profile/gateways/{id}', + options: { validate: { params: Joi.object({ id: Joi.string().required() }) }, handler: profile.deleteGateway }, + }, + { + method: 'GET', + path: '/api/payment/transactions', + options: { + validate: { + query: Joi.object({ + q: Joi.string().allow(''), + eventId: Joi.string().allow(''), + status: Joi.string().valid('pending', 'paid', 'failed', 'refunded', ''), + from: Joi.string().allow(''), + to: Joi.string().allow(''), + page: Joi.number().integer().min(1), + limit: Joi.number().integer().min(1).max(100), + }), + }, + handler: payment.listTransactions, + }, + }, + { + method: 'GET', + path: '/api/payment/transactions/export', + options: { + validate: { + query: Joi.object({ + q: Joi.string().allow(''), + eventId: Joi.string().allow(''), + status: Joi.string().allow(''), + from: Joi.string().allow(''), + to: Joi.string().allow(''), + }), + }, + handler: payment.exportTransactions, + }, + }, + { method: 'GET', path: '/api/payment/transactions/{id}', options: { handler: payment.getTransaction } }, + { + method: 'POST', + path: '/api/payment/create-order', + options: { validate: { payload: orderPayload }, handler: payment.createOrder }, + }, + { + method: 'POST', + path: '/api/payment/verify', + options: { validate: { payload: verifyPayload }, handler: payment.verify }, + }, + { + method: 'POST', + path: '/api/public/events/{eventId}/payment/create-order', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + payload: publicOrderPayload, + }, + handler: payment.publicCreateOrder, + }, + }, + { + method: 'POST', + path: '/api/public/events/{eventId}/payment/verify', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + payload: verifyPayload, + }, + handler: payment.publicVerify, + }, + }, + { + method: 'POST', + path: '/api/payment/webhooks/razorpay', + options: { + auth: false, + payload: { + output: 'data', + parse: false, + allow: 'application/json', + maxBytes: 1048576, + }, + handler: payment.razorpayWebhook, + }, + }, +]; diff --git a/src/routes/question.routes.js b/src/routes/question.routes.js new file mode 100644 index 0000000..fe14d51 --- /dev/null +++ b/src/routes/question.routes.js @@ -0,0 +1,25 @@ +const Joi = require('joi'); +const controller = require('../controllers/question.controller'); + +const questionPayload = Joi.object({ + session: Joi.string(), + participant: Joi.string(), + question: Joi.string().required(), + isAnonymous: Joi.boolean(), +}); + +const answerPayload = Joi.object({ + answer: Joi.string().required(), + isOfficial: Joi.boolean(), +}); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/questions', options: { handler: controller.list } }, + { method: 'POST', path: '/api/events/{eventId}/questions', options: { validate: { payload: questionPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/questions/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/questions/{id}', options: { validate: { payload: questionPayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/questions/{id}', options: { handler: controller.remove } }, + { method: 'POST', path: '/api/questions/{id}/upvote', options: { handler: controller.upvote } }, + { method: 'POST', path: '/api/questions/{id}/answer', options: { validate: { payload: answerPayload }, handler: controller.answer } }, + { method: 'GET', path: '/api/sessions/{sessionId}/questions', options: { handler: controller.sessionQuestions } }, +]; diff --git a/src/routes/quizPoll.routes.js b/src/routes/quizPoll.routes.js new file mode 100644 index 0000000..52662a0 --- /dev/null +++ b/src/routes/quizPoll.routes.js @@ -0,0 +1,62 @@ +const Joi = require('joi'); +const controller = require('../controllers/quizPoll.controller'); +const { QUIZ_POLL_TYPES, QUESTION_TYPES } = require('../config/constants'); + +const questionOptionSchema = Joi.object({ + text: Joi.string().allow(''), + isCorrect: Joi.boolean(), + imageUrl: Joi.string().allow('', null), +}); +const quizQuestionSchema = Joi.object({ + question: Joi.string().required(), + imageUrl: Joi.string().allow('', null), + type: Joi.string().valid(...QUESTION_TYPES), + options: Joi.array().items(questionOptionSchema), + points: Joi.number(), + required: Joi.boolean(), +}); +const quizPollPayload = Joi.object({ + session: Joi.string(), + title: Joi.string().required(), + type: Joi.string().valid(...QUIZ_POLL_TYPES).required(), + description: Joi.string(), + questions: Joi.array().items(quizQuestionSchema), + settings: Joi.object({ + status: Joi.string().valid('draft', 'active'), + isLive: Joi.boolean(), + displayMode: Joi.string().valid('always_live', 'always_sched', 'manual', 'quiz_master'), + startTime: Joi.date().allow(null), + endTime: Joi.date().allow(null), + showResults: Joi.boolean(), + allowMultipleAttempts: Joi.boolean(), + randomizeQuestions: Joi.boolean(), + timeRestricted: Joi.boolean(), + timeLimit: Joi.number().integer().min(0), + }), +}); + +const responsePayload = Joi.object({ + participantId: Joi.string(), + responses: Joi.array().items(Joi.object({ + questionIndex: Joi.number(), + answer: Joi.any(), + isCorrect: Joi.boolean(), + pointsEarned: Joi.number(), + })), + totalScore: Joi.number(), + timeSpent: Joi.number(), +}); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/quiz-polls', options: { handler: controller.list } }, + { method: 'POST', path: '/api/events/{eventId}/quiz-polls', options: { validate: { payload: quizPollPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/quiz-polls/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/quiz-polls/{id}', options: { validate: { payload: quizPollPayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/quiz-polls/{id}', options: { handler: controller.remove } }, + { method: 'POST', path: '/api/quiz-polls/{id}/publish', options: { handler: controller.publish } }, + { method: 'POST', path: '/api/quiz-polls/{id}/close', options: { handler: controller.close } }, + { method: 'GET', path: '/api/quiz-polls/{id}/results', options: { handler: controller.results } }, + { method: 'POST', path: '/api/quiz-polls/{quizPollId}/responses', options: { validate: { payload: responsePayload }, handler: controller.submitResponse } }, + { method: 'GET', path: '/api/quiz-polls/{quizPollId}/responses', options: { handler: controller.listResponses } }, + { method: 'GET', path: '/api/sessions/{sessionId}/quiz-polls', options: { handler: controller.sessionQuizPolls } }, +]; diff --git a/src/routes/rewardPoint.routes.js b/src/routes/rewardPoint.routes.js new file mode 100644 index 0000000..451060b --- /dev/null +++ b/src/routes/rewardPoint.routes.js @@ -0,0 +1,28 @@ +const Joi = require('joi'); +const controller = require('../controllers/rewardPoint.controller'); +const { REWARD_SOURCES } = require('../config/constants'); + +const rewardPayload = Joi.object({ + participant: Joi.string().required(), + points: Joi.number().required(), + type: Joi.string().valid('earned', 'redeemed', 'adjusted').required(), + source: Joi.string().valid(...REWARD_SOURCES), + sourceId: Joi.any(), + description: Joi.string(), + expiresAt: Joi.date(), +}); + +const bulkPayload = Joi.object({ + entries: Joi.array().items(rewardPayload).min(1), +}); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/reward-points', options: { handler: controller.list } }, + { method: 'POST', path: '/api/events/{eventId}/reward-points', options: { validate: { payload: rewardPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/reward-points/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/reward-points/{id}', options: { validate: { payload: rewardPayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/reward-points/{id}', options: { handler: controller.remove } }, + { method: 'GET', path: '/api/participants/{participantId}/reward-points', options: { handler: controller.byParticipant } }, + { method: 'GET', path: '/api/events/{eventId}/leaderboard', options: { handler: controller.leaderboard } }, + { method: 'POST', path: '/api/events/{eventId}/reward-points/bulk', options: { validate: { payload: bulkPayload }, handler: controller.bulk } }, +]; diff --git a/src/routes/session.routes.js b/src/routes/session.routes.js new file mode 100644 index 0000000..5bfbeb4 --- /dev/null +++ b/src/routes/session.routes.js @@ -0,0 +1,34 @@ +const Joi = require('joi'); +const controller = require('../controllers/session.controller'); +const { SESSION_TYPES, SESSION_STATUS } = require('../config/constants'); + +const sessionPayload = Joi.object({ + title: Joi.string().required(), + description: Joi.string().allow('', null), + type: Joi.string().valid(...SESSION_TYPES), + startTime: Joi.date().iso().required(), + endTime: Joi.date().iso().required(), + location: Joi.string().allow('', null), + track: Joi.string().allow('', null), + speakers: Joi.array().items(Joi.string()), + moderator: Joi.string().allow('', null), + capacity: Joi.number(), + status: Joi.string().valid(...SESSION_STATUS), + resources: Joi.array().items(Joi.object({ name: Joi.string(), type: Joi.string(), url: Joi.string() })), +}); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/sessions', options: { handler: controller.list } }, + { method: 'POST', path: '/api/events/{eventId}/sessions', options: { validate: { payload: sessionPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/sessions/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/sessions/{id}', options: { validate: { payload: sessionPayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/sessions/{id}', options: { handler: controller.remove } }, + { + method: 'PUT', + path: '/api/sessions/{id}/status', + options: { + validate: { payload: Joi.object({ status: Joi.string().valid(...SESSION_STATUS).required() }) }, + handler: controller.updateStatus, + }, + }, +]; diff --git a/src/routes/speaker.routes.js b/src/routes/speaker.routes.js new file mode 100644 index 0000000..4fd1d2c --- /dev/null +++ b/src/routes/speaker.routes.js @@ -0,0 +1,38 @@ +const Joi = require('joi'); +const controller = require('../controllers/speaker.controller'); + +const logisticsSchema = Joi.object({ + honorarium: Joi.boolean(), + honorariumAmount: Joi.alternatives().try(Joi.string(), Joi.number()).allow('', null), + travelRequired: Joi.boolean(), + accommodation: Joi.boolean(), + dietaryRequirements: Joi.string().allow('', null), + internalNotes: Joi.string().allow('', null), +}); + +const speakerPayload = Joi.object({ + firstName: Joi.string().required(), + lastName: Joi.string().required(), + email: Joi.string().email().required(), + phone: Joi.string().allow('', null), + title: Joi.string().allow('', null), + organizationName: Joi.string().allow('', null), + bio: Joi.string().allow('', null), + photo: Joi.string().allow('', null), + socialLinks: Joi.object({ linkedin: Joi.string(), twitter: Joi.string(), website: Joi.string() }), + expertise: Joi.array().items(Joi.string()), + isFeatured: Joi.boolean(), + organization: Joi.string(), + speakerRole: Joi.string().allow('', null), + profileVisibility: Joi.string().valid('public', 'private', 'Public', 'Private').allow('', null), + logistics: logisticsSchema.allow(null), +}); + +module.exports = [ + { method: 'GET', path: '/api/speakers', options: { handler: controller.list } }, + { method: 'POST', path: '/api/speakers', options: { validate: { payload: speakerPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/speakers/{id}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/speakers/{id}', options: { validate: { payload: speakerPayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/speakers/{id}', options: { handler: controller.remove } }, + { method: 'GET', path: '/api/events/{eventId}/speakers', options: { handler: controller.byEvent } }, +]; diff --git a/src/routes/subscription.routes.js b/src/routes/subscription.routes.js new file mode 100644 index 0000000..c505677 --- /dev/null +++ b/src/routes/subscription.routes.js @@ -0,0 +1,3 @@ +const controller = require('../controllers/subscription.controller'); + +module.exports = [{ method: 'GET', path: '/api/subscription/catalog', options: { handler: controller.catalog } }]; diff --git a/src/routes/track.routes.js b/src/routes/track.routes.js new file mode 100644 index 0000000..fc4dafa --- /dev/null +++ b/src/routes/track.routes.js @@ -0,0 +1,21 @@ +const Joi = require('joi'); +const controller = require('../controllers/track.controller'); + +const trackPayload = Joi.object({ + name: Joi.string().min(1).max(200).required(), + hall: Joi.string().allow('', null), + trackType: Joi.string().valid('general', 'workshop', 'plenary', 'symposium'), + status: Joi.string().valid('active', 'inactive'), + sortOrder: Joi.number(), +}); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/tracks', options: { handler: controller.list } }, + { + method: 'POST', + path: '/api/events/{eventId}/tracks', + options: { validate: { payload: trackPayload }, handler: controller.create }, + }, + { method: 'PUT', path: '/api/tracks/{id}', options: { validate: { payload: trackPayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/tracks/{id}', options: { handler: controller.remove } }, +]; diff --git a/src/routes/visitor.routes.js b/src/routes/visitor.routes.js new file mode 100644 index 0000000..00975b8 --- /dev/null +++ b/src/routes/visitor.routes.js @@ -0,0 +1,361 @@ +const Joi = require('joi'); +const controller = require('../controllers/visitor.controller'); +const visitorAuth = require('../controllers/visitorAuth.controller'); + +const joinPayload = Joi.object({ + email: Joi.string().email().required(), + joinType: Joi.string().valid('event', 'session', 'track').default('event'), + sessionId: Joi.string().allow('', null), + trackId: Joi.string().allow('', null), + inviteToken: Joi.string().allow('', null), +}); + +const verifyOtpPayload = Joi.object({ + email: Joi.string().email().required(), + otp: Joi.string().min(4).max(8).required(), + inviteToken: Joi.string().allow('', null), + firstName: Joi.string().allow('', null), + lastName: Joi.string().allow('', null), + phone: Joi.string().allow('', null), + displayName: Joi.string().allow('', null), +}); + +const exchangePayload = Joi.object({ + token: Joi.string().required(), +}); + +const guestPayload = Joi.object({ + joinType: Joi.string().valid('event', 'session', 'track').default('event'), + sessionId: Joi.string().allow('', null), + trackId: Joi.string().allow('', null), + inviteToken: Joi.string().allow('', null), + guestToken: Joi.string().allow('', null), + displayName: Joi.string().allow('', null), +}); + +const customFieldValueSchema = Joi.alternatives().try( + Joi.string().allow(''), + Joi.number(), + Joi.boolean(), + Joi.array().items(Joi.string()) +); + +const profilePayload = Joi.object({ + firstName: Joi.string().allow('', null), + lastName: Joi.string().allow('', null), + phone: Joi.string().allow('', null), + displayName: Joi.string().allow('', null), + customFields: Joi.object().pattern(Joi.string().max(120), customFieldValueSchema).max(40), +}); + +const visitorQuizPollResponsePayload = Joi.object({ + responses: Joi.array() + .items( + Joi.object({ + questionIndex: Joi.number().integer().min(0).required(), + answer: Joi.any().allow(null), + isCorrect: Joi.boolean(), + pointsEarned: Joi.number(), + }), + ) + .default([]), + totalScore: Joi.number(), + timeSpent: Joi.number(), +}); + +const micrositeContactPayload = Joi.object({ + eventId: Joi.string().required(), + notifyEmail: Joi.string().email().required(), + firstName: Joi.string().allow('', null), + lastName: Joi.string().allow('', null), + email: Joi.string().email().required(), + message: Joi.string().allow('', null), + /** Honeypot — must be empty */ + _gotcha: Joi.string().allow('', null), +}); + +module.exports = [ + { + method: 'POST', + path: '/api/visitor/resolve-code', + options: { + auth: false, + validate: { + payload: Joi.object({ + code: Joi.string().trim().min(1).required(), + }), + }, + handler: controller.resolveCode, + }, + }, + { + method: 'GET', + path: '/api/visitor/events/{eventId}/login-preview', + options: { + auth: false, + validate: { + params: Joi.object({ + eventId: Joi.string().required(), + }), + }, + handler: controller.loginPreview, + }, + }, + { + method: 'GET', + path: '/api/visitor/events/{eventId}/tracks', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + }, + handler: controller.listVisitorEventTracks, + }, + }, + { + method: 'GET', + path: '/api/visitor/events/{eventId}/agenda', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + }, + handler: controller.listVisitorEventAgenda, + }, + }, + { + method: 'GET', + path: '/api/visitor/events/{eventId}/quiz-polls', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + }, + handler: controller.listVisitorEventQuizPolls, + }, + }, + { + method: 'GET', + path: '/api/visitor/sessions/{sessionId}/preview', + options: { + auth: false, + validate: { + params: Joi.object({ sessionId: Joi.string().required() }), + query: Joi.object({ event_id: Joi.string().required() }), + }, + handler: controller.sessionPreview, + }, + }, + { + method: 'GET', + path: '/api/visitor/tracks/{trackId}/preview', + options: { + auth: false, + validate: { + params: Joi.object({ trackId: Joi.string().required() }), + query: Joi.object({ event_id: Joi.string().required() }), + }, + handler: controller.trackPreview, + }, + }, + { + method: 'GET', + path: '/api/visitor/sessions/{sessionId}/questions', + options: { + auth: false, + validate: { + params: Joi.object({ sessionId: Joi.string().required() }), + query: Joi.object({ event_id: Joi.string().required() }), + }, + handler: controller.listSessionQuestions, + }, + }, + { + method: 'POST', + path: '/api/visitor/sessions/{sessionId}/questions', + options: { + auth: { mode: 'optional', strategy: 'jwt-attendee' }, + validate: { + params: Joi.object({ sessionId: Joi.string().required() }), + query: Joi.object({ event_id: Joi.string().required() }), + payload: Joi.object({ + question: Joi.string().trim().min(1).max(4000).required(), + isAnonymous: Joi.boolean().default(false), + }), + }, + handler: controller.createSessionQuestion, + }, + }, + { + method: 'POST', + path: '/api/visitor/sessions/{sessionId}/questions/{questionId}/vote', + options: { + auth: { mode: 'optional', strategy: 'jwt-attendee' }, + validate: { + params: Joi.object({ + sessionId: Joi.string().required(), + questionId: Joi.string().required(), + }), + query: Joi.object({ event_id: Joi.string().required() }), + payload: Joi.object({ + voterKey: Joi.string().trim().min(8).max(128).required(), + direction: Joi.string().valid('up', 'down').required(), + }), + }, + handler: controller.voteSessionQuestion, + }, + }, + { + method: 'GET', + path: '/api/visitor/events/{eventId}/registration-status', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + query: Joi.object({ email: Joi.string().email().required() }), + }, + handler: visitorAuth.registrationStatus, + }, + }, + { + method: 'POST', + path: '/api/visitor/events/{eventId}/auth/start-otp', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + payload: joinPayload, + }, + handler: visitorAuth.startOtp, + }, + }, + { + method: 'POST', + path: '/api/visitor/events/{eventId}/auth/verify-otp', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + payload: verifyOtpPayload, + }, + handler: visitorAuth.verifyOtp, + }, + }, + { + method: 'POST', + path: '/api/visitor/events/{eventId}/auth/start-link', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + payload: joinPayload, + }, + handler: visitorAuth.startLink, + }, + }, + { + method: 'POST', + path: '/api/visitor/auth/exchange-link', + options: { + auth: false, + validate: { payload: exchangePayload }, + handler: visitorAuth.exchangeLink, + }, + }, + { + method: 'POST', + path: '/api/visitor/events/{eventId}/auth/guest', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + payload: guestPayload, + }, + handler: visitorAuth.guestStart, + }, + }, + { + method: 'PATCH', + path: '/api/visitor/me/profile', + options: { + auth: 'jwt-attendee', + validate: { payload: profilePayload }, + handler: visitorAuth.updateProfile, + }, + }, + { + method: 'GET', + path: '/api/visitor/events/{eventId}/session-bookmarks', + options: { + auth: 'jwt-attendee', + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + }, + handler: controller.listVisitorSessionBookmarks, + }, + }, + { + method: 'PUT', + path: '/api/visitor/events/{eventId}/session-bookmarks', + options: { + auth: 'jwt-attendee', + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + payload: Joi.object({ + sessionId: Joi.string().required(), + bookmarked: Joi.boolean().required(), + }), + }, + handler: controller.putVisitorSessionBookmark, + }, + }, + { + method: 'GET', + path: '/api/visitor/events/{eventId}/reward-balance', + options: { + auth: 'jwt-attendee', + validate: { + params: Joi.object({ eventId: Joi.string().required() }), + }, + handler: controller.visitorEventRewardBalance, + }, + }, + { + method: 'POST', + path: '/api/visitor/events/{eventId}/quiz-polls/{quizPollId}/responses', + options: { + auth: 'jwt-attendee', + validate: { + params: Joi.object({ + eventId: Joi.string().required(), + quizPollId: Joi.string().required(), + }), + payload: visitorQuizPollResponsePayload, + }, + handler: controller.submitVisitorQuizPollResponse, + }, + }, + { + method: 'GET', + path: '/api/visitor/events/{eventId}/quiz-polls/{quizPollId}/results-summary', + options: { + auth: 'jwt-attendee', + validate: { + params: Joi.object({ + eventId: Joi.string().required(), + quizPollId: Joi.string().required(), + }), + }, + handler: controller.getVisitorQuizPollResultsSummary, + }, + }, + { + method: 'POST', + path: '/api/visitor/microsite/contact', + options: { + auth: false, + validate: { payload: micrositeContactPayload }, + handler: controller.submitMicrositeContact, + }, + }, +]; diff --git a/src/routes/websiteSettings.routes.js b/src/routes/websiteSettings.routes.js new file mode 100644 index 0000000..9e40ee5 --- /dev/null +++ b/src/routes/websiteSettings.routes.js @@ -0,0 +1,73 @@ +const Joi = require('joi'); +const controller = require('../controllers/websiteSettings.controller'); +const { WEBSITE_SECTION_TYPES } = require('../config/constants'); + +const mongoId = Joi.string().length(24).hex().required(); +const optionalMongoId = Joi.string().length(24).hex().allow('', null); + +const websiteSettingsPayload = Joi.object({ + enabled: Joi.boolean(), + folder: Joi.string().allow('', null), + logo: Joi.string().allow('', null), + banners: Joi.array().items(Joi.string()), + favicon: Joi.string().allow('', null), + title: Joi.string().allow('', null), + description: Joi.string().allow('', null), + keywords: Joi.array().items(Joi.string()), + templateId: Joi.string().allow('', null), + layoutTemplate: Joi.string().valid('conference'), + seoDescription: Joi.string().allow('', null), + micrositeSections: Joi.array().items(Joi.string().valid(...WEBSITE_SECTION_TYPES)), + primaryColor: Joi.string().max(32).allow('', null), + accentColor: Joi.string().max(32).allow('', null), + fontPair: Joi.string().allow('', null), + registrationUrl: Joi.string().max(2048).allow('', null), + registrationFormId: optionalMongoId, + micrositeHtml: Joi.string().allow('').max(2000000), +}); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/website-settings', options: { validate: { params: Joi.object({ eventId: mongoId }) }, handler: controller.get } }, + { + method: 'GET', + path: '/api/events/{eventId}/website-settings/microsite-preview-html', + options: { + validate: { + params: Joi.object({ eventId: mongoId }), + query: Joi.object({ + micrositeId: Joi.string().length(24).hex().allow('', null), + }), + }, + handler: controller.previewMicrositeHtml, + }, + }, + { method: 'PUT', path: '/api/events/{eventId}/website-settings', options: { validate: { params: Joi.object({ eventId: mongoId }), payload: websiteSettingsPayload }, handler: controller.update } }, + { + method: 'GET', + path: '/api/events/{eventId}/website-settings/check-folder', + options: { + validate: { + params: Joi.object({ eventId: mongoId }), + query: Joi.object({ + folder: Joi.string().required(), + micrositeId: Joi.string().length(24).hex().allow('', null), + }), + }, + handler: controller.checkFolder, + }, + }, + { + method: 'POST', + path: '/api/events/{eventId}/website/publish-microsite', + options: { + validate: { + params: Joi.object({ eventId: mongoId }), + payload: Joi.object({ + targets: Joi.string().valid('draft', 'live', 'both'), + micrositeId: Joi.string().length(24).hex().allow('', null), + }), + }, + handler: controller.publishMicrosite, + }, + }, +]; diff --git a/src/services/featureAccess.js b/src/services/featureAccess.js new file mode 100644 index 0000000..9e1d3b5 --- /dev/null +++ b/src/services/featureAccess.js @@ -0,0 +1,85 @@ +const Boom = require('@hapi/boom'); +const { FEATURES, PLANS } = require('../config/subscriptionCatalog'); + +/** + * Subscription active if status not cancelled/past_due (when set) and not past expiresAt. + */ +function isSubscriptionActive(org) { + if (!org || !org.subscription) return true; + const sub = org.subscription; + if (sub.status === 'cancelled' || sub.status === 'past_due') return false; + if (sub.expiresAt && new Date(sub.expiresAt).getTime() < Date.now()) return false; + return true; +} + +/** + * @param {import('mongoose').Document | object} org - Organization doc or lean object + * @returns {{ featureKeys: Set, limits: { maxEvents?: number|null, maxParticipants?: number|null } }} + */ +function resolveEffectiveFeatures(org) { + const planKey = org?.subscription?.plan || 'free'; + const plan = PLANS[planKey] || PLANS.free; + const keys = new Set(plan.featureKeys); + const overrides = org?.subscription?.featureOverrides; + if (Array.isArray(overrides)) { + overrides.forEach((k) => { + if (typeof k === 'string' && FEATURES[k]) keys.add(k); + }); + } + const maxEvents = org?.subscription?.maxEvents ?? plan.defaultLimits?.maxEvents; + const maxParticipants = org?.subscription?.maxParticipants ?? plan.defaultLimits?.maxParticipants; + return { + featureKeys: keys, + limits: { maxEvents, maxParticipants }, + }; +} + +/** + * @param {object} org + * @param {string} featureKey + * @returns {boolean} + */ +function hasFeature(org, featureKey) { + if (!isSubscriptionActive(org)) return false; + const { featureKeys } = resolveEffectiveFeatures(org); + return featureKeys.has(featureKey); +} + +/** + * @param {object} org + * @param {string} featureKey + * @param {{ minEvents?: number }} [params] reserved for future parametric checks + */ +function assertFeature(org, featureKey, params) { + if (!FEATURES[featureKey]) return false; + if (!hasFeature(org, featureKey)) return false; + void params; + return true; +} + +/** + * Hapi lifecycle method: `options: { pre: [{ method: requireFeaturePre('api_access') }] }` + */ +function requireFeaturePre(featureKey) { + return async (request, h) => { + const user = request.auth?.credentials?.user; + const org = user?.organization; + let orgDoc = org; + if (org && !org.subscription && user.organization?._id) { + const Organization = require('../models/Organization'); + orgDoc = await Organization.findById(user.organization._id || user.organization).lean(); + } + if (!assertFeature(orgDoc, featureKey)) { + throw Boom.forbidden('This feature is not included in your plan.'); + } + return h.continue; + }; +} + +module.exports = { + isSubscriptionActive, + resolveEffectiveFeatures, + hasFeature, + assertFeature, + requireFeaturePre, +}; diff --git a/src/services/payments/adapters/cashfreeAdapter.js b/src/services/payments/adapters/cashfreeAdapter.js new file mode 100644 index 0000000..77c734a --- /dev/null +++ b/src/services/payments/adapters/cashfreeAdapter.js @@ -0,0 +1,5 @@ +const { notImplemented } = require('./stubAdapter'); + +module.exports = { + createOrder: () => notImplemented('cashfree'), +}; diff --git a/src/services/payments/adapters/ccavenueAdapter.js b/src/services/payments/adapters/ccavenueAdapter.js new file mode 100644 index 0000000..9cacd5c --- /dev/null +++ b/src/services/payments/adapters/ccavenueAdapter.js @@ -0,0 +1,5 @@ +const { notImplemented } = require('./stubAdapter'); + +module.exports = { + createOrder: () => notImplemented('ccavenue'), +}; diff --git a/src/services/payments/adapters/index.js b/src/services/payments/adapters/index.js new file mode 100644 index 0000000..d14377a --- /dev/null +++ b/src/services/payments/adapters/index.js @@ -0,0 +1,12 @@ +const razorpay = require('./razorpayAdapter'); +const { notImplemented } = require('./stubAdapter'); + +async function createGatewayOrder(provider, cred, input) { + if (provider === 'razorpay') return razorpay.createOrder(cred, input); + notImplemented(provider); +} + +module.exports = { + createGatewayOrder, + razorpay, +}; diff --git a/src/services/payments/adapters/paytmAdapter.js b/src/services/payments/adapters/paytmAdapter.js new file mode 100644 index 0000000..3bda370 --- /dev/null +++ b/src/services/payments/adapters/paytmAdapter.js @@ -0,0 +1,5 @@ +const { notImplemented } = require('./stubAdapter'); + +module.exports = { + createOrder: () => notImplemented('paytm'), +}; diff --git a/src/services/payments/adapters/payuAdapter.js b/src/services/payments/adapters/payuAdapter.js new file mode 100644 index 0000000..e63fb9f --- /dev/null +++ b/src/services/payments/adapters/payuAdapter.js @@ -0,0 +1,5 @@ +const { notImplemented } = require('./stubAdapter'); + +module.exports = { + createOrder: () => notImplemented('payu'), +}; diff --git a/src/services/payments/adapters/razorpayAdapter.js b/src/services/payments/adapters/razorpayAdapter.js new file mode 100644 index 0000000..6c01b58 --- /dev/null +++ b/src/services/payments/adapters/razorpayAdapter.js @@ -0,0 +1,57 @@ +const crypto = require('crypto'); + +let Razorpay; +try { + Razorpay = require('razorpay'); +} catch { + Razorpay = null; +} + +/** + * @param {{ keyId: string, keySecret: string }} cred + * @param {{ amountMinor: number, currency: string, receipt: string, notes?: object }} input + */ +async function createOrder(cred, input) { + if (!Razorpay) { + const e = new Error('Razorpay SDK not installed'); + e.code = 'RAZORPAY_SDK_MISSING'; + throw e; + } + const r = new Razorpay({ key_id: cred.keyId, key_secret: cred.keySecret }); + const order = await r.orders.create({ + amount: input.amountMinor, + currency: (input.currency || 'INR').toUpperCase(), + receipt: String(input.receipt).slice(0, 40), + notes: input.notes || {}, + }); + return { externalOrderId: order.id, order }; +} + +function verifyPaymentSignature(orderId, paymentId, signature, keySecret) { + const body = `${orderId}|${paymentId}`; + const expected = crypto.createHmac('sha256', keySecret).update(body).digest('hex'); + const got = String(signature || ''); + if (expected.length !== got.length) return false; + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got)); + } catch { + return false; + } +} + +/** + * Razorpay webhook: X-Razorpay-Signature = HMAC SHA256 of raw body with webhook secret + */ +function verifyWebhookSignature(rawBodyBuffer, signatureHeader, webhookSecret) { + if (!webhookSecret || !signatureHeader) return false; + const expected = crypto.createHmac('sha256', webhookSecret).update(rawBodyBuffer).digest('hex'); + const got = String(signatureHeader); + if (expected.length !== got.length) return false; + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got)); + } catch { + return false; + } +} + +module.exports = { createOrder, verifyPaymentSignature, verifyWebhookSignature }; diff --git a/src/services/payments/adapters/stripeAdapter.js b/src/services/payments/adapters/stripeAdapter.js new file mode 100644 index 0000000..6eddd74 --- /dev/null +++ b/src/services/payments/adapters/stripeAdapter.js @@ -0,0 +1,5 @@ +const { notImplemented } = require('./stubAdapter'); + +module.exports = { + createOrder: () => notImplemented('stripe'), +}; diff --git a/src/services/payments/adapters/stubAdapter.js b/src/services/payments/adapters/stubAdapter.js new file mode 100644 index 0000000..72df696 --- /dev/null +++ b/src/services/payments/adapters/stubAdapter.js @@ -0,0 +1,12 @@ +function notImplemented(provider) { + const e = new Error(`${provider} gateway is not implemented yet`); + e.code = 'GATEWAY_NOT_IMPLEMENTED'; + e.provider = provider; + throw e; +} + +async function createOrder() { + notImplemented('generic'); +} + +module.exports = { createOrder, notImplemented }; diff --git a/src/services/payments/createOrderCore.js b/src/services/payments/createOrderCore.js new file mode 100644 index 0000000..e62b55a --- /dev/null +++ b/src/services/payments/createOrderCore.js @@ -0,0 +1,156 @@ +const PaymentTransaction = require('../../models/PaymentTransaction'); +const PlatformPaymentConfig = require('../../models/PlatformPaymentConfig'); +const { resolveCheckout, computeAmounts } = require('./resolver'); +const { createGatewayOrder } = require('./adapters'); +const { getOrCreateProfile } = require('./organizationPaymentProfileService'); + +/** + * @param {object} args + * @param {import('mongoose').Types.ObjectId} args.organizationId + * @param {import('mongoose').Types.ObjectId} [args.eventId] + * @param {import('mongoose').Types.ObjectId} [args.participantId] + * @param {number} args.amountSubtotalMinor + * @param {string} [args.currency] + * @param {string} [args.idempotencyKey] + * @param {object} [args.registrationContext] + */ +async function createPaymentOrderCore(args) { + const { + organizationId, + eventId, + participantId, + amountSubtotalMinor, + currency: overrideCurrency, + idempotencyKey, + registrationContext, + } = args; + + const subtotal = Math.round(Number(amountSubtotalMinor)); + if (!Number.isFinite(subtotal) || subtotal < 1) { + const e = new Error('amountSubtotal must be a positive integer (minor units)'); + e.code = 'INVALID_AMOUNT'; + throw e; + } + + const profile = await getOrCreateProfile(organizationId); + const platform = await PlatformPaymentConfig.findOne({ key: 'default' }); + const ctx = await resolveCheckout(profile, platform); + const effectiveCurrency = (overrideCurrency || ctx.currency || 'INR').toUpperCase(); + const { commissionAmount, amountGross, amountNet } = computeAmounts(subtotal, ctx.commissionBps); + + if (idempotencyKey) { + const existing = await PaymentTransaction.findOne({ + organization: organizationId, + idempotencyKey: String(idempotencyKey), + }); + if (existing) { + if (existing.amountSubtotal !== subtotal || existing.amountGross !== amountGross) { + const bad = new Error('Idempotency key reused with different amount'); + bad.code = 'IDEMPOTENCY_MISMATCH'; + throw bad; + } + if (existing.status === 'paid') { + const done = new Error('Payment already completed for this idempotency key'); + done.code = 'IDEMPOTENCY_COMPLETE'; + done.transaction = existing; + throw done; + } + if (existing.externalOrderId) { + return { + transaction: existing, + keyId: ctx.keyId, + orderId: existing.externalOrderId, + amount: amountGross, + currency: effectiveCurrency, + checkoutSource: existing.checkoutSource, + commissionAmount: existing.commissionAmount, + amountSubtotal: existing.amountSubtotal, + amountGross: existing.amountGross, + amountNet: existing.amountNet, + }; + } + const receipt = `eb_${existing._id.toString().slice(-12)}`; + const orderResult = await createGatewayOrder( + ctx.gatewayProvider, + { keyId: ctx.keyId, keySecret: ctx.keySecret }, + { + amountMinor: amountGross, + currency: effectiveCurrency, + receipt, + notes: { + txId: existing._id.toString(), + eventId: eventId ? String(eventId) : '', + participantId: participantId ? String(participantId) : '', + }, + }, + ); + existing.externalOrderId = orderResult.externalOrderId; + existing.ledgerKeyId = ctx.keyId || ''; + await existing.save(); + return { + transaction: existing, + keyId: ctx.keyId, + orderId: orderResult.externalOrderId, + amount: amountGross, + currency: effectiveCurrency, + checkoutSource: existing.checkoutSource, + commissionAmount: existing.commissionAmount, + amountSubtotal: existing.amountSubtotal, + amountGross: existing.amountGross, + amountNet: existing.amountNet, + }; + } + } + + const tx = await PaymentTransaction.create({ + organization: organizationId, + event: eventId || null, + participant: participantId || null, + checkoutSource: ctx.checkoutSource, + gatewayProvider: ctx.gatewayProvider, + orgGatewayId: ctx.orgGatewayId || null, + currency: effectiveCurrency, + amountSubtotal: subtotal, + commissionAmount, + amountGross, + amountNet, + status: 'pending', + idempotencyKey: idempotencyKey ? String(idempotencyKey) : '', + ledgerKeyId: ctx.keyId || '', + registrationContext: registrationContext || null, + }); + + const receipt = `eb_${tx._id.toString().slice(-12)}`; + const orderResult = await createGatewayOrder( + ctx.gatewayProvider, + { keyId: ctx.keyId, keySecret: ctx.keySecret }, + { + amountMinor: amountGross, + currency: effectiveCurrency, + receipt, + notes: { + txId: tx._id.toString(), + eventId: eventId ? String(eventId) : '', + participantId: participantId ? String(participantId) : '', + }, + }, + ); + + tx.externalOrderId = orderResult.externalOrderId; + await tx.save(); + + return { + transaction: tx, + keyId: ctx.keyId, + orderId: orderResult.externalOrderId, + amount: amountGross, + currency: effectiveCurrency, + checkoutSource: ctx.checkoutSource, + commissionAmount, + amountSubtotal: subtotal, + amountGross, + amountNet, + }; +} + +module.exports = { createPaymentOrderCore }; diff --git a/src/services/payments/maskCredentials.js b/src/services/payments/maskCredentials.js new file mode 100644 index 0000000..de8002c --- /dev/null +++ b/src/services/payments/maskCredentials.js @@ -0,0 +1,17 @@ +/** @param {string} [id] */ +function maskKeyId(id) { + if (!id || typeof id !== 'string') return ''; + const s = id.trim(); + if (s.length <= 8) return '****'; + return `${s.slice(0, 4)}…${s.slice(-4)}`; +} + +/** @param {object} cred */ +function publicGatewayShape(provider, cred) { + if (!cred || typeof cred !== 'object') return {}; + if (provider === 'razorpay' && cred.keyId) return { keyId: maskKeyId(cred.keyId) }; + if (provider === 'stripe' && cred.publishableKey) return { publishableKey: maskKeyId(cred.publishableKey) }; + return { configured: true }; +} + +module.exports = { maskKeyId, publicGatewayShape }; diff --git a/src/services/payments/organizationPaymentProfileService.js b/src/services/payments/organizationPaymentProfileService.js new file mode 100644 index 0000000..f023f1b --- /dev/null +++ b/src/services/payments/organizationPaymentProfileService.js @@ -0,0 +1,81 @@ +const OrganizationPaymentProfile = require('../../models/OrganizationPaymentProfile'); +const PaymentConfig = require('../../models/PaymentConfig'); +const { encryptObject } = require('../../utils/paymentCrypto'); + +const LEGACY_MAP = { + razorpay: 'razorpay', + stripe: 'stripe', + paypal: null, + manual: null, +}; + +/** + * @param {import('mongoose').Types.ObjectId|string} orgId + */ +async function getOrCreateProfile(orgId) { + let profile = await OrganizationPaymentProfile.findOne({ organization: orgId }); + if (profile) return profile; + + const legacy = await PaymentConfig.findOne({ organization: orgId }); + const gateways = []; + if (legacy && LEGACY_MAP[legacy.gateway]) { + const cred = {}; + if (legacy.credentials && typeof legacy.credentials.forEach === 'function') { + legacy.credentials.forEach((v, k) => { + cred[String(k)] = String(v); + }); + } + const enc = + Object.keys(cred).length > 0 + ? encryptObject(cred) + : encryptObject({ placeholder: 'migrated' }); + gateways.push({ + provider: LEGACY_MAP[legacy.gateway], + label: `${legacy.gateway} (migrated)`, + isActive: Boolean(legacy.isActive), + isPrimary: true, + testMode: legacy.testMode !== false, + credentialsEnc: enc, + }); + } + + profile = await OrganizationPaymentProfile.create({ + organization: orgId, + checkoutMode: gateways.length > 0 ? 'custom' : 'platform_razorpay', + defaultCurrency: legacy?.currency || 'INR', + taxRate: legacy?.taxRate ?? null, + taxType: legacy?.taxType || '', + gateways, + }); + return profile; +} + +function serializeGateway(g) { + const obj = g.toObject ? g.toObject() : { ...g }; + delete obj.credentialsEnc; + return { + ...obj, + credentials: {}, + }; +} + +/** + * @param {import('mongoose').Types.ObjectId|string} orgId + * @param {(doc: object) => void} enrichGateway optional decrypt and add public cred hints + */ +async function getProfileForApi(orgId, enrichGateway) { + const profile = await getOrCreateProfile(orgId); + const plain = profile.toObject(); + plain.gateways = (profile.gateways || []).map((g) => { + const base = serializeGateway(g); + if (enrichGateway) enrichGateway(base, g); + return base; + }); + return plain; +} + +module.exports = { + getOrCreateProfile, + getProfileForApi, + serializeGateway, +}; diff --git a/src/services/payments/resolver.js b/src/services/payments/resolver.js new file mode 100644 index 0000000..8e35b69 --- /dev/null +++ b/src/services/payments/resolver.js @@ -0,0 +1,181 @@ +const PlatformPaymentConfig = require('../../models/PlatformPaymentConfig'); +const OrganizationPaymentProfile = require('../../models/OrganizationPaymentProfile'); +const { decryptObject, decryptString } = require('../../utils/paymentCrypto'); + +/** + * @param {number} subtotalMinor + * @param {number} bps + */ +function commissionFromSubtotal(subtotalMinor, bps) { + const b = Math.max(0, Math.min(10000, Number(bps) || 0)); + return Math.round((subtotalMinor * b) / 10000); +} + +/** + * Resolve checkout: credentials source + commission + amounts (minor units). + * @param {import('mongoose').Document} orgProfile lean or doc + * @param {object|null} platformDoc PlatformPaymentConfig doc or null + */ +async function resolveCheckout(orgProfile, platformDoc) { + const mode = orgProfile.checkoutMode || 'platform_razorpay'; + const currency = orgProfile.defaultCurrency || 'INR'; + + if (mode === 'platform_razorpay') { + const platform = platformDoc || (await PlatformPaymentConfig.findOne({ key: 'default' })); + if (!platform || !platform.enabled) { + const err = new Error('Platform checkout is not enabled. Configure platform keys or use a custom gateway.'); + err.code = 'PLATFORM_CHECKOUT_DISABLED'; + throw err; + } + let cred; + try { + cred = + platform.razorpayCredentialsEnc && platform.razorpayCredentialsEnc.length > 8 + ? decryptObject(platform.razorpayCredentialsEnc) + : {}; + } catch { + cred = {}; + } + if (!cred.keyId || !cred.keySecret) { + const envId = process.env.RAZORPAY_PLATFORM_KEY_ID; + const envSecret = process.env.RAZORPAY_PLATFORM_KEY_SECRET; + if (envId && envSecret) cred = { keyId: envId, keySecret: envSecret }; + } + if (!cred.keyId || !cred.keySecret) { + const err = new Error('Platform Razorpay is not configured'); + err.code = 'PLATFORM_RAZORPAY_MISSING'; + throw err; + } + const defaultBps = platform.defaultCommissionBps ?? 200; + const orgBps = orgProfile.platformCommissionBps != null ? orgProfile.platformCommissionBps : defaultBps; + let webhookSecret = ''; + try { + if (platform.webhookSecretEnc) webhookSecret = decryptString(platform.webhookSecretEnc); + } catch { + webhookSecret = process.env.RAZORPAY_PLATFORM_WEBHOOK_SECRET || ''; + } + return { + checkoutSource: 'platform_razorpay', + gatewayProvider: 'razorpay', + orgGatewayId: null, + keyId: cred.keyId, + keySecret: cred.keySecret, + testMode: platform.testMode !== false, + commissionBps: orgBps, + currency, + webhookSecret, + }; + } + + const gateways = orgProfile.gateways || []; + const primary = + gateways.find((g) => g.isPrimary && g.isActive) || gateways.find((g) => g.isActive); + if (!primary) { + const err = new Error('No active payment gateway. Add and activate a gateway or use platform checkout.'); + err.code = 'NO_GATEWAY'; + throw err; + } + let cred; + try { + cred = decryptObject(primary.credentialsEnc); + } catch (e) { + const err = new Error('Invalid gateway credentials'); + err.code = 'BAD_GATEWAY_CREDENTIALS'; + throw err; + } + + if (primary.provider === 'razorpay') { + if (!cred.keyId || !cred.keySecret) { + const err = new Error('Razorpay keyId and keySecret required'); + err.code = 'BAD_GATEWAY_CREDENTIALS'; + throw err; + } + return { + checkoutSource: 'custom_gateway', + gatewayProvider: 'razorpay', + orgGatewayId: primary._id, + keyId: cred.keyId, + keySecret: cred.keySecret, + testMode: primary.testMode !== false, + commissionBps: 0, + currency, + webhookSecret: cred.webhookSecret || '', + }; + } + + const err = new Error(`Gateway "${primary.provider}" is not implemented yet`); + err.code = 'GATEWAY_NOT_IMPLEMENTED'; + err.provider = primary.provider; + throw err; +} + +function computeAmounts(subtotalMinor, commissionBps) { + const commissionAmount = commissionFromSubtotal(subtotalMinor, commissionBps); + const amountGross = subtotalMinor + commissionAmount; + const amountNet = subtotalMinor; + return { commissionAmount, amountGross, amountNet }; +} + +/** + * Credentials for verify/webhook matching how the order was created (stable if org later changes mode). + * @param {import('mongoose').Document} tx PaymentTransaction + */ +async function resolveForPaymentTransaction(tx) { + const platform = await PlatformPaymentConfig.findOne({ key: 'default' }); + const orgProfile = await OrganizationPaymentProfile.findOne({ organization: tx.organization }); + if (!orgProfile) { + const err = new Error('Organization payment profile not found'); + err.code = 'NO_PROFILE'; + throw err; + } + + if (tx.checkoutSource === 'platform_razorpay') { + const forced = orgProfile.toObject ? orgProfile.toObject() : { ...orgProfile }; + forced.checkoutMode = 'platform_razorpay'; + return resolveCheckout(forced, platform); + } + + const g = orgProfile.gateways.id(tx.orgGatewayId); + if (!g) { + const err = new Error('Original gateway no longer exists'); + err.code = 'GATEWAY_MISSING'; + throw err; + } + let cred; + try { + cred = decryptObject(g.credentialsEnc); + } catch { + const err = new Error('Invalid gateway credentials'); + err.code = 'BAD_GATEWAY_CREDENTIALS'; + throw err; + } + if (g.provider !== 'razorpay') { + const err = new Error(`Gateway "${g.provider}" is not implemented yet`); + err.code = 'GATEWAY_NOT_IMPLEMENTED'; + throw err; + } + if (!cred.keyId || !cred.keySecret) { + const err = new Error('Razorpay keyId and keySecret required'); + err.code = 'BAD_GATEWAY_CREDENTIALS'; + throw err; + } + const currency = orgProfile.defaultCurrency || 'INR'; + return { + checkoutSource: 'custom_gateway', + gatewayProvider: 'razorpay', + orgGatewayId: g._id, + keyId: cred.keyId, + keySecret: cred.keySecret, + testMode: g.testMode !== false, + commissionBps: 0, + currency, + webhookSecret: cred.webhookSecret || '', + }; +} + +module.exports = { + resolveCheckout, + resolveForPaymentTransaction, + commissionFromSubtotal, + computeAmounts, +}; diff --git a/src/services/payments/verifyPaymentCore.js b/src/services/payments/verifyPaymentCore.js new file mode 100644 index 0000000..0927f37 --- /dev/null +++ b/src/services/payments/verifyPaymentCore.js @@ -0,0 +1,51 @@ +const PaymentTransaction = require('../../models/PaymentTransaction'); +const Participant = require('../../models/Participant'); +const { resolveForPaymentTransaction } = require('./resolver'); +const { razorpay: razorpayAdapter } = require('./adapters'); + +function minorToMajor(amountMinor, currency) { + void currency; + return Math.round(Number(amountMinor)) / 100; +} + +/** + * @param {{ orderId: string, paymentId: string, signature: string, organizationId: import('mongoose').Types.ObjectId|string }} p + */ +async function applyPaidStatus(tx, paymentId) { + tx.status = 'paid'; + tx.externalPaymentId = paymentId; + await tx.save(); + + if (tx.participant) { + await Participant.findByIdAndUpdate(tx.participant, { + $set: { + 'payment.amount': minorToMajor(tx.amountGross, tx.currency), + 'payment.currency': tx.currency, + 'payment.status': 'paid', + 'payment.transactionId': paymentId, + 'payment.paidAt': new Date(), + }, + }); + } +} + +async function verifyPaymentCore(p) { + const { orderId, paymentId, signature, organizationId } = p; + const tx = await PaymentTransaction.findOne({ organization: organizationId, externalOrderId: orderId }); + if (!tx) return { error: 'NOT_FOUND', message: 'Transaction not found' }; + if (tx.status === 'paid') return { ok: true, transaction: tx, already: true }; + + const ctx = await resolveForPaymentTransaction(tx); + if (tx.ledgerKeyId && ctx.keyId !== tx.ledgerKeyId) { + return { error: 'CONFIG_CHANGED', message: 'Payment configuration changed before verification' }; + } + + const ok = razorpayAdapter.verifyPaymentSignature(orderId, paymentId, signature, ctx.keySecret); + if (!ok) return { error: 'INVALID_SIGNATURE', message: 'Invalid payment signature' }; + + await applyPaidStatus(tx, paymentId); + + return { ok: true, transaction: tx }; +} + +module.exports = { verifyPaymentCore, minorToMajor, applyPaidStatus }; diff --git a/src/utils/conferenceTheme.js b/src/utils/conferenceTheme.js new file mode 100644 index 0000000..ef103b9 --- /dev/null +++ b/src/utils/conferenceTheme.js @@ -0,0 +1,159 @@ +/** + * Build inline :root theme and Google Fonts link for conference microsites. + * Variables must match microsites/conference/css/conference-layout.css. + */ + +const DEFAULT_PRIMARY = '#5c6ac4'; +const DEFAULT_ACCENT = '#00c2d1'; +/** Matches first option in CreateMicrositePanel FONT_PAIRS */ +const DEFAULT_FONT_PAIR = 'Montserrat + Roboto'; + +const FONT_SPECS = { + 'Montserrat + Roboto': { + href: 'https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700;800&family=Roboto:wght@300;400;700&display=swap', + heading: '"Montserrat", system-ui, sans-serif', + body: '"Roboto", system-ui, sans-serif', + }, + 'Inter + Noto Sans': { + href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Noto+Sans:wght@300;400;700&display=swap', + heading: '"Inter", system-ui, sans-serif', + body: '"Noto Sans", system-ui, sans-serif', + }, + 'Playfair + Source Sans': { + href: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700;800&family=Source+Sans+3:wght@300;400;700&display=swap', + heading: '"Playfair Display", Georgia, serif', + body: '"Source Sans 3", system-ui, sans-serif', + }, + 'Poppins + Open Sans': { + href: 'https://fonts.googleapis.com/css2?family=Poppins:wght@600;700;800&family=Open+Sans:wght@300;400;700&display=swap', + heading: '"Poppins", system-ui, sans-serif', + body: '"Open Sans", system-ui, sans-serif', + }, + 'Raleway + Lato': { + href: 'https://fonts.googleapis.com/css2?family=Raleway:wght@600;700;800&family=Lato:wght@300;400;700&display=swap', + heading: '"Raleway", system-ui, sans-serif', + body: '"Lato", system-ui, sans-serif', + }, +}; + +function normalizeHex(input) { + if (input == null) return null; + let s = String(input).trim(); + if (!s) return null; + if (!s.startsWith('#')) s = `#${s}`; + if (/^#([0-9a-fA-F]{3})$/.test(s)) { + const x = s.slice(1); + s = `#${x[0]}${x[0]}${x[1]}${x[1]}${x[2]}${x[2]}`; + } + if (!/^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/.test(s)) return null; + return s.length === 9 ? s.slice(0, 7) : s; +} + +function hexToRgb(hex) { + const h = normalizeHex(hex); + if (!h) return null; + const n = parseInt(h.slice(1), 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +function rgbToHex(r, g, b) { + const c = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); + return `#${c(r)}${c(g)}${c(b)}`; +} + +/** 0..1 mix toward black */ +function darken(hex, amount) { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + const f = 1 - amount; + return rgbToHex(rgb.r * f, rgb.g * f, rgb.b * f); +} + +/** 0..1 mix toward white */ +function lighten(hex, amount) { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + const f = amount; + return rgbToHex(rgb.r + (255 - rgb.r) * f, rgb.g + (255 - rgb.g) * f, rgb.b + (255 - rgb.b) * f); +} + +function relativeLuminance(hex) { + const rgb = hexToRgb(hex); + if (!rgb) return 0; + const lin = (c) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b); +} + +/** Contrasting text on accent: near-black or near-white */ +function accentForeground(accentHex) { + const L = relativeLuminance(accentHex); + return L > 0.45 ? '#0d2137' : '#ffffff'; +} + +function hexToRgbaString(hex, alpha) { + const rgb = hexToRgb(hex); + if (!rgb) return `rgba(61, 77, 168, ${alpha})`; + return `rgba(${rgb.r},${rgb.g},${rgb.b},${alpha})`; +} + +function fontSpec(website) { + const key = (website && website.fontPair && String(website.fontPair).trim()) || DEFAULT_FONT_PAIR; + return FONT_SPECS[key] || FONT_SPECS[DEFAULT_FONT_PAIR]; +} + +/** + * Google Fonts stylesheet link (single line, for ). + * @param {object} website event.website + * @returns {string} + */ +function buildConferenceFontLink(website) { + const spec = fontSpec(website); + return ``; +} + +/** + * Inline `; +} + +module.exports = { + buildConferenceThemeStyleTag, + buildConferenceFontLink, + DEFAULT_FONT_PAIR, + FONT_SPECS, +}; diff --git a/src/utils/csvParticipant.js b/src/utils/csvParticipant.js new file mode 100644 index 0000000..1fa8af9 --- /dev/null +++ b/src/utils/csvParticipant.js @@ -0,0 +1,108 @@ +/** Parse CSV text into array of row objects using first row as headers. */ +function parseLine(line) { + const out = []; + let cur = ''; + let inQ = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === '"') { + inQ = !inQ; + continue; + } + if (!inQ && c === ',') { + out.push(cur); + cur = ''; + continue; + } + cur += c; + } + out.push(cur); + return out.map((s) => s.trim()); +} + +function normalizeKey(h) { + const c = String(h || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); + const aliases = { + email: 'email', + 'e-mail': 'email', + 'email address': 'email', + 'first name': 'firstName', + firstname: 'firstName', + first_name: 'firstName', + first: 'firstName', + givenname: 'firstName', + 'last name': 'lastName', + lastname: 'lastName', + last_name: 'lastName', + last: 'lastName', + surname: 'lastName', + familyname: 'lastName', + phone: 'phone', + mobile: 'phone', + telephone: 'phone', + tel: 'phone', + organization: 'organization', + org: 'organization', + institution: 'organization', + company: 'organization', + affiliation: 'organization', + designation: 'designation', + role: 'designation', + jobtitle: 'designation', + title: 'designation', + tags: 'tags', + tag: 'tags', + notes: 'notes', + note: 'notes', + remarks: 'notes', + }; + const compact = c.replace(/[^a-z]/g, ''); + if (aliases[c]) return aliases[c]; + if (aliases[compact]) return aliases[compact]; + if (compact === 'firstname') return 'firstName'; + if (compact === 'lastname') return 'lastName'; + return null; +} + +function parseParticipantCsv(text) { + const lines = String(text || '') + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length < 2) return { headers: [], rows: [] }; + + const headerCells = parseLine(lines[0]); + const keys = headerCells.map((h) => normalizeKey(h) || h.trim().replace(/\s+/g, '_').toLowerCase()); + + const rows = []; + for (let i = 1; i < lines.length; i++) { + const cells = parseLine(lines[i]); + if (cells.every((c) => !c)) continue; + const row = {}; + keys.forEach((k, j) => { + row[k] = cells[j] ?? ''; + }); + rows.push(row); + } + return { keys, rows }; +} + +function splitTags(s) { + if (!s || !String(s).trim()) return []; + return String(s) + .split(/[;,]/) + .map((t) => t.trim()) + .filter(Boolean); +} + +function escapeCsvField(v) { + if (v == null) return ''; + const s = String(v); + if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} + +module.exports = { parseParticipantCsv, splitTags, escapeCsvField, parseLine }; diff --git a/src/utils/eventBuzzCode.js b/src/utils/eventBuzzCode.js new file mode 100644 index 0000000..3b9386c --- /dev/null +++ b/src/utils/eventBuzzCode.js @@ -0,0 +1,24 @@ +/** Deterministic 10-char A–Z / 0–9 code for a track or session id (stable across opens). Mirrors frontend eventBuzzCode.ts. */ +const EVENTBUZZ_CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +/** + * @param {string} id Mongo document id string + * @param {'track' | 'session'} kind + */ +function eventBuzzCodeFor(id, kind) { + const input = `${kind}:${id}`; + let h = 2166136261; + for (let i = 0; i < input.length; i += 1) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 16777619); + } + let n = h >>> 0; + let code = ''; + for (let i = 0; i < 10; i += 1) { + n = (Math.imul(n, 1664525) + 1013904223) >>> 0; + code += EVENTBUZZ_CODE_CHARS[n % 36]; + } + return code; +} + +module.exports = { eventBuzzCodeFor }; diff --git a/src/utils/firebase.js b/src/utils/firebase.js new file mode 100644 index 0000000..6857ac1 --- /dev/null +++ b/src/utils/firebase.js @@ -0,0 +1,72 @@ +/** + * Firebase Admin (Realtime Database) — optional. + * Configure with FIREBASE_DATABASE_URL + one of: + * - GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/serviceAccount.json + * - FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...} (single line, deployment) + * - FIREBASE_SERVICE_ACCOUNT_PATH=/path/to.json (alias for explicit path) + * + * If FIREBASE_DATABASE_URL is unset, sync helpers no-op (Mongo remains source of truth). + */ +const admin = require('firebase-admin'); + +let dbInstance = null; +let initAttempted = false; +let initError = null; + +function initFirebaseAdmin() { + if (initAttempted) return; + initAttempted = true; + const databaseURL = process.env.FIREBASE_DATABASE_URL; + if (!databaseURL) { + return; + } + + try { + if (admin.apps.length > 0) { + dbInstance = admin.database(); + return; + } + + let credential; + if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) { + const raw = process.env.FIREBASE_SERVICE_ACCOUNT_JSON.trim(); + credential = admin.credential.cert(JSON.parse(raw)); + } else if (process.env.FIREBASE_SERVICE_ACCOUNT_PATH) { + // eslint-disable-next-line import/no-dynamic-require, global-require + const sa = require(process.env.FIREBASE_SERVICE_ACCOUNT_PATH); + credential = admin.credential.cert(sa); + } else if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { + credential = admin.credential.applicationDefault(); + } else { + console.warn( + '[firebase] FIREBASE_DATABASE_URL is set but no credentials. Set GOOGLE_APPLICATION_CREDENTIALS, FIREBASE_SERVICE_ACCOUNT_PATH, or FIREBASE_SERVICE_ACCOUNT_JSON.', + ); + return; + } + + admin.initializeApp({ + credential, + databaseURL, + }); + dbInstance = admin.database(); + } catch (e) { + initError = e; + console.error('[firebase] Failed to initialize Firebase Admin:', e.message); + } +} + +/** @returns {import('firebase-admin').database.Database | null} */ +function getFirebaseDb() { + initFirebaseAdmin(); + if (initError) return null; + return dbInstance; +} + +function isFirebaseConfigured() { + return Boolean(process.env.FIREBASE_DATABASE_URL); +} + +module.exports = { + getFirebaseDb, + isFirebaseConfigured, +}; diff --git a/src/utils/googleIdToken.js b/src/utils/googleIdToken.js new file mode 100644 index 0000000..7b69c45 --- /dev/null +++ b/src/utils/googleIdToken.js @@ -0,0 +1,54 @@ +const { OAuth2Client } = require('google-auth-library'); + +function getGoogleClientIds() { + const raw = process.env.GOOGLE_CLIENT_ID || ''; + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * @param {string} idToken + * @returns {Promise<{ sub: string, email: string, firstName: string, lastName: string, picture: string }>} + */ +async function verifyGoogleIdToken(idToken) { + const audience = getGoogleClientIds(); + if (!audience.length) { + const err = new Error('Google Sign-In is not configured'); + err.code = 'GOOGLE_NOT_CONFIGURED'; + throw err; + } + const client = new OAuth2Client(); + let ticket; + try { + ticket = await client.verifyIdToken({ + idToken: String(idToken), + audience, + }); + } catch { + const err = new Error('Invalid Google credential'); + err.code = 'INVALID_GOOGLE_TOKEN'; + throw err; + } + const payload = ticket.getPayload(); + if (!payload || !payload.email) { + const err = new Error('Google account has no email'); + err.code = 'INVALID_GOOGLE_TOKEN'; + throw err; + } + if (payload.email_verified !== true) { + const err = new Error('Google email is not verified'); + err.code = 'EMAIL_NOT_VERIFIED'; + throw err; + } + return { + sub: payload.sub, + email: String(payload.email).toLowerCase().trim(), + firstName: payload.given_name || '', + lastName: payload.family_name || '', + picture: payload.picture || '', + }; +} + +module.exports = { verifyGoogleIdToken, getGoogleClientIds }; diff --git a/src/utils/mail.js b/src/utils/mail.js new file mode 100644 index 0000000..483b017 --- /dev/null +++ b/src/utils/mail.js @@ -0,0 +1,197 @@ +const nodemailer = require('nodemailer'); + +let transporter; + +/** MAIL_* (SendGrid / Laravel-style) with SMTP_* fallback */ +function resolveSmtp() { + const host = process.env.MAIL_HOST || process.env.SMTP_HOST; + const port = parseInt(process.env.MAIL_PORT || process.env.SMTP_PORT || '587', 10); + const enc = String(process.env.MAIL_ENCRYPTION || '').toLowerCase(); + const secure = + enc === 'ssl' || port === 465 || process.env.SMTP_SECURE === 'true'; + const user = process.env.MAIL_USERNAME || process.env.SMTP_USER; + const pass = process.env.MAIL_PASSWORD || process.env.SMTP_PASS; + return { host, port, secure, user, pass }; +} + +function getMailFrom() { + const addr = + process.env.MAIL_FROM_ADDRESS || + process.env.SMTP_FROM || + process.env.MAIL_USERNAME || + process.env.SMTP_USER || + 'noreply@eventbuzz.local'; + let name = process.env.MAIL_FROM_NAME; + if (name) { + name = String(name).replace(/^["']|["']$/g, '').trim(); + const safe = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${safe}" <${addr}>`; + } + return addr; +} + +function getTransporter() { + if (transporter) return transporter; + const { host, port, secure, user, pass } = resolveSmtp(); + if (!host) return null; + transporter = nodemailer.createTransport({ + host, + port, + secure, + auth: user ? { user, pass: pass || '' } : undefined, + }); + return transporter; +} + +/** + * @returns {Promise} + */ +async function sendEventInviteEmail({ to, acceptUrl, eventName, orgName, role }) { + const from = getMailFrom(); + const subject = `You're invited to help with "${eventName}"`; + const text = `Hello, + +You've been invited to join "${eventName}" (${orgName || 'Event'}) as ${role || 'team member'}. + +Accept your invitation and set up access here: +${acceptUrl} + +This link expires in 14 days. + +— EventBuzz`; + + const html = `

Hello,

+

You've been invited to join ${escapeHtml(eventName)}${orgName ? ` (${escapeHtml(orgName)})` : ''} as ${escapeHtml(role || 'team member')}.

+

Accept invitation

+

Or copy this link: ${acceptUrl}

+

This link expires in 14 days.

+

— EventBuzz

`; + + const t = getTransporter(); + if (!t) { + console.log('[EventInvite email skipped — set MAIL_HOST or SMTP_HOST]\nTo:', to, '\nLink:', acceptUrl); + return true; + } + try { + await t.sendMail({ from, to, subject, text, html }); + return true; + } catch (e) { + console.error('sendEventInviteEmail', e.message); + return false; + } +} + +function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +async function sendVisitorOtpEmail({ to, otp, eventName }) { + const from = getMailFrom(); + const name = eventName || 'your event'; + const subject = `Your sign-in code for ${name}`; + const text = `Your one-time code is: ${otp} + +It expires in 10 minutes. + +— EventBuzz`; + + const html = `

Your one-time code for ${escapeHtml(name)} is:

+

${escapeHtml(otp)}

+

This code expires in 10 minutes.

+

— EventBuzz

`; + + const t = getTransporter(); + if (!t) { + console.log(`[visitor OTP — no MAIL_HOST/SMTP_HOST] to=${to} code=${otp}`); + return true; + } + try { + await t.sendMail({ from, to, subject, text, html }); + return true; + } catch (e) { + console.error('sendVisitorOtpEmail', e.message); + return false; + } +} + +async function sendVisitorMagicLinkEmail({ to, magicLinkUrl, eventName }) { + const from = getMailFrom(); + const name = eventName || 'your event'; + const subject = `Sign in to ${name}`; + const text = `Tap or open this link to sign in (expires in 15 minutes): + +${magicLinkUrl} + +— EventBuzz`; + + const html = `

Sign in to ${escapeHtml(name)}:

+

Sign in

+

Or copy: ${escapeHtml(magicLinkUrl)}

+

This link expires in 15 minutes.

+

— EventBuzz

`; + + const t = getTransporter(); + if (!t) { + console.log(`[visitor magic link — no MAIL_HOST/SMTP_HOST] to=${to}\n${magicLinkUrl}`); + return true; + } + try { + await t.sendMail({ from, to, subject, text, html }); + return true; + } catch (e) { + console.error('sendVisitorMagicLinkEmail', e.message); + return false; + } +} + +/** + * Microsite "Contact us" form: notify organizer-defined email (published static sites). + * @param {{ to: string, eventName?: string, visitorEmail: string, firstName?: string, lastName?: string, message?: string }} opts + * @returns {Promise} + */ +async function sendMicrositeContactEmail({ + to, + eventName, + visitorEmail, + firstName, + lastName, + message, +}) { + const from = getMailFrom(); + const who = [firstName, lastName].filter(Boolean).join(' ').trim() || visitorEmail; + const subject = `Microsite contact: ${eventName || 'Event'}`; + const text = `New message from ${who} <${visitorEmail}> + +${message || '(no message)'} + +— Sent via EventBuzz microsite contact form`; + + const html = `

New message from ${escapeHtml(who)} <${escapeHtml(visitorEmail)}>

+

${escapeHtml(message || '(no message)')}

+

Event: ${escapeHtml(eventName || '—')}

+

— EventBuzz microsite

`; + + const t = getTransporter(); + if (!t) { + console.log(`[microsite contact — no MAIL_HOST/SMTP_HOST] to=${to}\n${text}`); + return true; + } + try { + await t.sendMail({ from, to, subject, text, html }); + return true; + } catch (e) { + console.error('sendMicrositeContactEmail', e.message); + return false; + } +} + +module.exports = { + sendEventInviteEmail, + sendVisitorOtpEmail, + sendVisitorMagicLinkEmail, + sendMicrositeContactEmail, +}; diff --git a/src/utils/micrositeBuild.js b/src/utils/micrositeBuild.js new file mode 100644 index 0000000..94d1ab2 --- /dev/null +++ b/src/utils/micrositeBuild.js @@ -0,0 +1,635 @@ +const fs = require('fs/promises'); +const path = require('path'); +const Session = require('../models/Session'); +const Speaker = require('../models/Speaker'); +const EventPage = require('../models/EventPage'); +const { buildSectionsHtml, escapeHtml } = require('./websiteHtml'); +const { buildConferenceThemeStyleTag, buildConferenceFontLink } = require('./conferenceTheme'); +const { WEBSITE_SECTION_TYPES } = require('../config/constants'); + +const LAYOUT_TO_FOLDER = { conference: 'conference' }; + +/** + * @param {object} event + * @returns {string} + */ +function registrationHref(event, website) { + const w = website || event.website || {}; + const template = process.env.MICROSITE_REGISTRATION_URL_TEMPLATE || ''; + const formId = w.registrationFormId != null ? String(w.registrationFormId).trim() : ''; + const eid = event._id != null ? String(event._id) : ''; + if (template && formId && eid) { + return template.replace(/\{eventId\}/g, eid).replace(/\{formId\}/g, formId); + } + const legacy = w.registrationUrl != null ? String(w.registrationUrl).trim() : ''; + return legacy; +} + +/** + * Registration URL for an explicit form id (visual builder CTA). Uses same template vars as {@link registrationHref}. + * @param {object} event + * @param {object} [website] + * @param {string} [explicitFormId] Mongo id of Form + * @returns {string} + */ +function registrationUrlForForm(event, website, explicitFormId) { + const w = website || event.website || {}; + const template = process.env.MICROSITE_REGISTRATION_URL_TEMPLATE || ''; + const fid = + explicitFormId != null && String(explicitFormId).trim() + ? String(explicitFormId).trim() + : w.registrationFormId != null + ? String(w.registrationFormId).trim() + : ''; + const eid = event._id != null ? String(event._id) : ''; + if (template && fid && eid) { + return template.replace(/\{eventId\}/g, eid).replace(/\{formId\}/g, fid); + } + const legacy = w.registrationUrl != null ? String(w.registrationUrl).trim() : ''; + return legacy; +} + +/** + * @param {object} [website] website bag (event.website shape) + * @returns {string} + */ +function normalizeLayoutKey(website) { + const w = website || {}; + const t = w.layoutTemplate || 'conference'; + if (t === 'conference') return 'conference'; + return 'conference'; +} + +/** + * @param {object} event + * @returns {string} + */ +function buildCountdownInlineScript(event) { + const start = event.startDate ? new Date(event.startDate).getTime() : NaN; + const nameJson = JSON.stringify(event.name || 'Event'); + const valid = Number.isFinite(start); + const startExpr = valid ? String(start) : 'null'; + return ``; +} + +const REPO_ROOT = path.join(__dirname, '..', '..', '..'); + +/** + * @param {object} event + * @returns {string} + */ +function formatHeroMeta(event) { + const loc = event.location || ''; + const start = event.startDate ? new Date(event.startDate) : null; + const end = event.endDate ? new Date(event.endDate) : null; + let datePart = ''; + if (start && !Number.isNaN(start.getTime())) { + const dopts = { month: 'short', day: 'numeric', year: 'numeric' }; + if (end && !Number.isNaN(end.getTime()) && start.toDateString() !== end.toDateString()) { + datePart = `${start.toLocaleDateString('en-US', dopts)} \u2013 ${end.toLocaleDateString('en-US', dopts)}`; + } else { + datePart = start.toLocaleDateString('en-US', dopts); + } + } + return [datePart, loc].filter(Boolean).join(' \u00b7 '); +} + +/** + * @param {object} event + * @returns {string} + */ +function heroBackgroundImage(event, website) { + if (event.bannerImage) return String(event.bannerImage); + const w = website || event.website || {}; + if (Array.isArray(w.banners) && w.banners.length && w.banners[0]) return String(w.banners[0]); + return ''; +} + +/** + * @param {string} layoutKey + * @returns {string} + */ +/** + * @param {string} type + * @param {object} event mongoose doc or plain + * @param {object} [website] website bag + * @returns {object} + */ +function defaultConfigFor(type, event, website) { + const w = website || event.website || {}; + const name = event.name || 'Event'; + const sub = event.subtitle || ''; + const desc = event.description || ''; + const reg = registrationHref(event, w) || ''; + const regAnchor = reg || '#register'; + switch (type) { + case 'header': + return { + logoText: name, + logoUrl: w.logo ? String(w.logo) : '', + menuItems: [ + { label: 'About', url: '#about' }, + { label: 'Speakers', url: '#speakers' }, + { label: 'Schedule', url: '#schedule' }, + { label: 'Register', url: regAnchor }, + ], + }; + case 'banner': + return { + headline: name, + subtext: sub || desc.slice(0, 280), + ctaText: 'Register', + ctaUrl: regAnchor, + secondaryCtaText: 'View schedule', + secondaryCtaUrl: '#schedule', + backgroundClass: 'bg-primary', + backgroundImage: heroBackgroundImage(event, w), + heroMetaLine: formatHeroMeta(event), + }; + case 'contact_form': + return { title: 'Contact us' }; + case 'agenda': + return { title: 'Schedule' }; + case 'register_links': + return { + title: 'Register', + primaryLabel: 'Register now', + primaryUrl: regAnchor, + }; + case 'speakers': + return { title: 'Speakers', limit: 12 }; + case 'footer': + return { copyrightText: `© ${new Date().getFullYear()} ${name}. All rights reserved.` }; + case 'sponsors': + return { + title: 'Sponsors & partners', + sponsors: [], + partnerCtaLabel: 'Become a partner', + partnerCtaUrl: '#', + }; + case 'cta': + return { headline: 'Join us', subtext: sub, buttonLabel: 'Register', buttonUrl: regAnchor }; + case 'custom_html': + return { + html: desc + ? `

${escapeHtml(desc)}

` + : `

Event details coming soon.

`, + }; + case 'countdown': { + const start = event.startDate ? new Date(event.startDate) : null; + const line = start && !Number.isNaN(start.getTime()) + ? `Event starts · ${start.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })}` + : `Event starts · ${name}`; + return { headingLine: line }; + } + case 'gallery': + return { + title: 'Gallery', + images: [ + { url: 'https://images.unsplash.com/photo-1475721027785-f74eccf877e2?auto=format&fit=crop&w=800&q=80', alt: 'Conference audience (placeholder)' }, + { url: 'https://images.unsplash.com/photo-1505373877841-8d25f7d46678?auto=format&fit=crop&w=800&q=80', alt: 'Stage (placeholder)' }, + ], + }; + case 'about': + return { + title: 'About the event', + lead: desc ? desc.slice(0, 400) : `Learn more about ${name}.`, + bulletsTitle: 'Why attend', + bullets: [ + 'Hands-on sessions and practical takeaways', + 'Networking with peers and sponsors', + 'Product tips from the EventBuzz team', + ], + ctaLabel: 'Reserve your seat', + ctaUrl: regAnchor, + }; + case 'venue': + return { + title: 'Venue', + venueName: event.location || 'Venue TBA', + intro: event.location ? `Join us at ${event.location}.` : 'Venue details will be announced soon.', + mapEmbedUrl: 'https://www.google.com/maps?q=0,0&z=15&hl=en&output=embed', + transportCards: [ + { icon: 'airplane', title: 'By plane', text: 'Check airport transfers and local transit.' }, + { icon: 'train-front', title: 'By train', text: 'Nearest station within walking distance.' }, + { icon: 'bus-front', title: 'By bus', text: 'Regional lines may stop nearby.' }, + { icon: 'car-front', title: 'Parking', text: 'Parking information will be shared before the event.' }, + ], + }; + case 'faq': + return { + title: 'Frequently Asked Questions', + items: [ + { question: `When and where is ${name}?`, answer: formatHeroMeta(event) || 'Details coming soon.' }, + { question: 'What is included with my ticket?', answer: 'See the registration page for tier details (demo answer).' }, + { question: 'Will sessions be recorded?', answer: 'Recording policy will be communicated by the organizers.' }, + ], + contactEmail: 'events@example.com', + }; + case 'tickets': + return { + title: 'Tickets', + intro: 'Choose the tier that fits you (demo pricing).', + tiers: [ + { name: 'Community', price: '199', blurb: 'Livestream + digital access (demo).', featured: false, buttonLabel: 'Choose', buttonUrl: regAnchor }, + { name: 'Professional', price: '449', blurb: 'Full access, workshops (demo).', featured: true, buttonLabel: 'Buy now', buttonUrl: regAnchor }, + { name: 'VIP', price: '799', blurb: 'Reserved seating + extras (demo).', featured: false, buttonLabel: 'Choose', buttonUrl: regAnchor }, + ], + includedTitle: "What's included (demo)", + includedBullets: ['Session recordings where available', 'Templates and resources', 'Community badge on the hub'], + }; + default: + return {}; + } +} + +/** + * Overlay Website Builder section config on publish defaults so live agenda/speakers + * and event-backed fields still fill gaps. + * @param {string} type + * @param {object|undefined|null} saved + * @param {object} event + * @returns {object} + */ +function mergeSectionConfig(type, saved, event, website) { + const base = defaultConfigFor(type, event, website); + if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return base; + const out = { ...base, ...saved }; + // Website Builder seeds empty strings; treat as “use event default” for key display fields. + if (type === 'header' && !String(out.logoText || '').trim()) { + out.logoText = base.logoText; + } + if (type === 'banner') { + if (!String(out.headline || '').trim()) out.headline = base.headline; + if (!String(out.subtext || '').trim()) out.subtext = base.subtext; + if (!String(out.secondaryCtaText || '').trim()) out.secondaryCtaText = base.secondaryCtaText; + } + return out; +} + +/** + * Publish section list: + * - When Configure Microsite has a non-empty `micrositeSections` checklist, that order wins + * (so the panel is not ignored by an older Website Builder Home page). + * Config for each type is taken from the first matching Home section when present; otherwise defaults. + * - When the checklist is empty, use Website Builder Home sections if any; else synthetic from panel array. + * @param {object} event + * @param {object} website event.website-shaped bag + * @param {import('mongoose').Types.ObjectId|string|null|undefined} sourcePageId optional EventPage for sections + * @returns {Promise<{ title: string, sections: object[] }>} + */ +async function resolvePublishPage(event, website, sourcePageId) { + const allowed = new Set(WEBSITE_SECTION_TYPES); + let homePage = null; + if (sourcePageId) { + const p = await EventPage.findById(sourcePageId).lean(); + const evMatch = p && p.event && event._id && String(p.event) === String(event._id); + if (evMatch && Array.isArray(p.sections) && p.sections.length > 0) { + homePage = p; + } + } + if (!homePage) { + const pages = await EventPage.find({ event: event._id }) + .sort({ 'settings.menuOrder': 1, createdAt: 1 }) + .lean(); + homePage = + pages.find((p) => p.type === 'home' && Array.isArray(p.sections) && p.sections.length > 0) || + pages.find((p) => Array.isArray(p.sections) && p.sections.length > 0); + } + + const panelTypes = (website.micrositeSections || []).filter((t) => allowed.has(t)); + + const resolveTitle = () => + (website && website.title) || + (homePage && homePage.seo && homePage.seo.title) || + (homePage && homePage.title) || + event.name || + 'Event'; + + if (panelTypes.length > 0) { + const homeSections = + homePage && Array.isArray(homePage.sections) ? homePage.sections : []; + const usedHomeIdx = new Set(); + const sections = panelTypes.map((type, i) => { + let saved = null; + for (let j = 0; j < homeSections.length; j++) { + const s = homeSections[j]; + if (!s || typeof s.type !== 'string' || !allowed.has(s.type)) continue; + if (usedHomeIdx.has(j) || s.type !== type) continue; + usedHomeIdx.add(j); + saved = s.config; + break; + } + return { + id: `${type}-${i}`, + type, + order: i, + config: mergeSectionConfig(type, saved, event, website), + }; + }); + return { title: resolveTitle(), sections }; + } + + if (homePage && Array.isArray(homePage.sections) && homePage.sections.length > 0) { + const sections = homePage.sections + .filter((s) => s && typeof s.type === 'string' && allowed.has(s.type)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((s, i) => ({ + id: s.id || `${s.type}-${i}`, + type: s.type, + order: s.order ?? i, + config: mergeSectionConfig(s.type, s.config, event, website), + })); + if (sections.length > 0) { + return { title: resolveTitle(), sections }; + } + } + return buildSyntheticPage(website.micrositeSections || [], event, website); +} + +/** + * @param {string[]} micrositeSections + * @param {object} event + * @returns {{ title: string, sections: object[] }} + */ +function buildSyntheticPage(micrositeSections, event, website) { + const w = website || event.website || {}; + const allowed = new Set(WEBSITE_SECTION_TYPES); + const types = (micrositeSections || []).filter((t) => allowed.has(t)); + const title = (w.title && String(w.title).trim()) || event.name || 'Event'; + return { + title, + sections: types.map((type, i) => ({ + id: `${type}-${i}`, + type, + order: i, + config: defaultConfigFor(type, event, w), + })), + }; +} + +function formatSessionTimeRange(start, end) { + if (!start) return ''; + const a = new Date(start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + if (!end) return a; + const b = new Date(end).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + return `${a} – ${b}`; +} + +async function loadAgendaAndSpeakers(eventId) { + const [sessions, speakerList] = await Promise.all([ + Session.find({ event: eventId }).populate('track').populate('speakers').sort({ startTime: 1 }).lean(), + (async () => { + const eventSessions = await Session.find({ event: eventId }).select('speakers').lean(); + const speakerIds = [...new Set(eventSessions.flatMap((s) => (s.speakers || []).map((id) => id.toString())))]; + if (speakerIds.length === 0) return []; + return Speaker.find({ _id: { $in: speakerIds }, profileVisibility: 'public' }).lean(); + })(), + ]); + const agendaItems = sessions.map((s) => { + const rawSp = Array.isArray(s.speakers) && s.speakers[0] ? s.speakers[0] : null; + const sp = rawSp && typeof rawSp === 'object' && 'firstName' in rawSp ? rawSp : null; + const speakerName = sp ? [sp.firstName, sp.lastName].filter(Boolean).join(' ') : ''; + return { + title: s.title, + time: formatSessionTimeRange(s.startTime, s.endTime), + track: s.location || s.track?.name || s.track?.title || '', + description: s.description || '', + speakerName, + speakerPhotoUrl: sp?.photo ? String(sp.photo) : '', + }; + }); + const speakers = speakerList.map((s) => ({ + name: [s.firstName, s.lastName].filter(Boolean).join(' '), + designation: s.title || s.organizationName || '', + photoUrl: s.photo || '', + })); + return { agendaItems, speakers }; +} + +/** + * Compose the same HTML document the static publish step writes (conference shell + theme + main inject), + * without touching the filesystem. + * + * @param {object} event Event mongoose doc or lean + * @param {object} [opts] + * @param {string} [opts.registrationUrl] + * @param {boolean} [opts.previewOnly=false] When true, slug/folder is not required (preview in editor). + * @param {boolean} [opts.applyVisualMainOverride=true] When true, saved visual HTML replaces generated main markup (publish path). + * @param {string} [opts.publishFolderOverride] When set, must be the slug folder or `{slug}_draft` (validated). + * @returns {Promise<{ html: string, publishFolder: string|null, folderDir: string }>} + */ +async function buildMicrositeDocumentHtml(event, opts = {}) { + const previewOnly = !!opts.previewOnly; + const applyVisualMainOverride = opts.applyVisualMainOverride !== false; + const website = opts.website != null ? opts.website : event.website || {}; + const sourcePageId = opts.sourcePageId; + const layoutKey = normalizeLayoutKey(website); + const folderDir = LAYOUT_TO_FOLDER[layoutKey]; + if (!folderDir) { + throw new Error('Invalid layoutTemplate'); + } + const shellPath = path.join(REPO_ROOT, 'microsites', folderDir, 'shell.html'); + let shell; + try { + shell = await fs.readFile(shellPath, 'utf8'); + } catch (e) { + throw new Error(`Microsite shell not found: ${shellPath}`); + } + + const page = await resolvePublishPage(event, website, sourcePageId); + if (!page.sections || page.sections.length === 0) { + throw new Error( + 'No sections to publish. Add sections on the Website Builder Home page, or select sections under Microsite settings.', + ); + } + const { agendaItems, speakers } = await loadAgendaAndSpeakers(event._id); + let registrationUrl = + typeof opts.registrationUrl === 'string' && opts.registrationUrl.trim() + ? opts.registrationUrl.trim() + : registrationHref(event, website); + if (!registrationUrl) registrationUrl = ''; + + let sectionsHtml = buildSectionsHtml(page, event, { + agendaItems, + speakers, + registrationUrl, + layoutTemplate: layoutKey, + }); + + if (applyVisualMainOverride && event._id) { + let raw = website.micrositeHtml != null ? String(website.micrositeHtml).trim() : ''; + if (!raw && sourcePageId) { + const srcVisual = await EventPage.findById(sourcePageId).select('micrositeHtml').lean(); + raw = srcVisual?.micrositeHtml != null ? String(srcVisual.micrositeHtml).trim() : ''; + } + if (!raw) { + const homeVisual = await EventPage.findOne({ event: event._id, type: 'home' }).select('micrositeHtml').lean(); + raw = homeVisual?.micrositeHtml != null ? String(homeVisual.micrositeHtml).trim() : ''; + } + if (raw) sectionsHtml = raw; + } + + const htmlTitle = (website.title && String(website.title).trim()) || page.title || event.name || 'Event'; + const pageTitle = escapeHtml(htmlTitle); + const metaRaw = + (website.seoDescription && String(website.seoDescription).trim()) || + (website.description && String(website.description).trim()) || + (event.description && String(event.description).trim()) || + ''; + const metaDesc = escapeHtml(metaRaw.slice(0, 320)); + const headInject = `${pageTitle}\n `; + + const apiBaseForMicrosite = process.env.MICROSITE_API_BASE_URL || ''; + let html = shell + .replace('', escapeHtml(apiBaseForMicrosite)) + .replace('', headInject) + .replace('', sectionsHtml) + .replace('', buildCountdownInlineScript(event)); + + if (folderDir === 'conference') { + const fontLink = buildConferenceFontLink(website); + const themeTag = buildConferenceThemeStyleTag(website); + html = html + .replace('', fontLink) + .replace('', themeTag); + } + + const folder = (website.folder || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, ''); + if (!previewOnly && !folder) { + throw new Error('Website folder (slug) is required'); + } + const draftName = `${folder}_draft`; + const allowedFolders = new Set([folder, draftName]); + let publishFolder; + if (previewOnly) { + publishFolder = null; + } else if (opts.publishFolderOverride != null && String(opts.publishFolderOverride).trim()) { + const ov = String(opts.publishFolderOverride).trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''); + if (!allowedFolders.has(ov)) { + throw new Error('Invalid publish folder target'); + } + publishFolder = ov; + } else { + publishFolder = website.enabled ? folder : draftName; + } + return { html, publishFolder, folderDir }; +} + +/** + * @param {object} event Event mongoose doc (lean or hydrated) + * @param {object} opts + * @param {string} opts.staticRoot + * @param {string} [opts.registrationUrl] + * @param {string} [opts.publishFolderOverride] `folder` or `{folder}_draft` + * @returns {Promise<{ diskPath: string, html: string, publishFolder: string }>} + */ +async function buildMicrositeHtml(event, opts) { + const website = opts.website != null ? opts.website : event.website || {}; + const folder = (website.folder || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, ''); + if (!folder) { + throw new Error('Website folder (slug) is required'); + } + + const { html, publishFolder, folderDir } = await buildMicrositeDocumentHtml(event, { + registrationUrl: opts.registrationUrl, + previewOnly: false, + applyVisualMainOverride: true, + publishFolderOverride: opts.publishFolderOverride, + website, + sourcePageId: opts.sourcePageId, + }); + + const outDir = path.join(opts.staticRoot, publishFolder); + await fs.mkdir(outDir, { recursive: true }); + if (folderDir === 'conference') { + const layoutSrc = path.join(REPO_ROOT, 'microsites', 'conference', 'css', 'conference-layout.css'); + await fs.copyFile(layoutSrc, path.join(outDir, 'conference-layout.css')); + const staticCssSrc = path.join(REPO_ROOT, 'microsites', 'conference', 'static', 'conference-static.css'); + await fs.copyFile(staticCssSrc, path.join(outDir, 'conference-static.css')); + const contactJsSrc = path.join(REPO_ROOT, 'microsites', 'conference', 'static', 'eb-microsite-contact.js'); + await fs.copyFile(contactJsSrc, path.join(outDir, 'eb-microsite-contact.js')); + } + const diskPath = path.join(outDir, 'index.html'); + await fs.writeFile(diskPath, html, 'utf8'); + + return { diskPath, html, publishFolder }; +} + +/** + * CSS payload for the GrapesJS canvas so layouts match published conference microsites (theme vars + layout + static). + * @param {object} website event.website + * @returns {Promise<{ fontLinkTag: string, themeStyleTag: string, layoutCss: string, staticCss: string }>} + */ +/** + * Map EventMicrosite document (lean) to the same shape as event.website for layout/publish. + * @param {object} template + * @returns {object} + */ +function templateToWebsiteBag(template) { + if (!template || typeof template !== 'object') return {}; + const t = template; + return { + enabled: t.enabled, + folder: t.publishFolder != null ? String(t.publishFolder).trim().toLowerCase().replace(/[^a-z0-9-]/g, '') : '', + logo: t.logo, + banners: Array.isArray(t.banners) ? t.banners : [], + favicon: t.favicon, + title: t.title, + description: t.description, + keywords: Array.isArray(t.keywords) ? t.keywords : [], + templateId: t.templateId, + layoutTemplate: t.layoutTemplate || 'conference', + micrositeSections: Array.isArray(t.micrositeSections) ? t.micrositeSections : [], + primaryColor: t.primaryColor, + accentColor: t.accentColor, + fontPair: t.fontPair, + registrationUrl: t.registrationUrl, + registrationFormId: t.registrationFormId, + seoDescription: t.seoDescription, + micrositeHtml: t.micrositeHtml, + }; +} + +async function getConferenceCanvasStylePayload(website) { + const fontLinkTag = buildConferenceFontLink(website || {}); + const themeStyleTag = buildConferenceThemeStyleTag(website || {}); + const layoutPath = path.join(REPO_ROOT, 'microsites', 'conference', 'css', 'conference-layout.css'); + const staticPath = path.join(REPO_ROOT, 'microsites', 'conference', 'static', 'conference-static.css'); + const [layoutCss, staticCss] = await Promise.all([ + fs.readFile(layoutPath, 'utf8'), + fs.readFile(staticPath, 'utf8'), + ]); + return { fontLinkTag, themeStyleTag, layoutCss, staticCss }; +} + +module.exports = { + buildMicrositeHtml, + buildMicrositeDocumentHtml, + getConferenceCanvasStylePayload, + templateToWebsiteBag, + buildSyntheticPage, + defaultConfigFor, + LAYOUT_TO_FOLDER, + registrationUrlForForm, +}; diff --git a/src/utils/participantMembership.js b/src/utils/participantMembership.js new file mode 100644 index 0000000..25fc6be --- /dev/null +++ b/src/utils/participantMembership.js @@ -0,0 +1,79 @@ +const mongoose = require('mongoose'); + +/** + * @param {object} participant lean or document + * @param {string|import('mongoose').Types.ObjectId} eventId + */ +function getMembership(participant, eventId) { + if (!participant || !Array.isArray(participant.eventMemberships)) return null; + const eid = String(eventId); + return participant.eventMemberships.find((m) => String(m.event) === eid) || null; +} + +function participantInEvent(participant, eventId) { + return Boolean(getMembership(participant, eventId)); +} + +/** + * API shape expected by dashboards / eventbuzz (single-event slice). + * @param {object} participant lean + * @param {string|import('mongoose').Types.ObjectId} eventId + */ +function flattenParticipantForEvent(participant, eventId) { + if (!participant) return null; + const m = getMembership(participant, eventId); + if (!m) return null; + const p = { ...participant }; + delete p.eventMemberships; + const rootTags = Array.isArray(participant.tags) ? participant.tags : []; + const legacyMembershipTags = Array.isArray(m.tags) ? m.tags : []; + const tags = [...new Set([...rootTags, ...legacyMembershipTags])]; + return { + ...p, + event: String(eventId), + status: m.status, + checkedIn: m.checkedIn, + checkedInAt: m.checkedInAt, + guestToken: m.guestToken, + registrationDate: m.registrationDate, + tags, + }; +} + +/** Capacity & auth: guest rows may have no email. */ +function membershipEmailQuery(tenantOrgId, eventId, email) { + const em = String(email || '').trim().toLowerCase(); + return { + tenantOrganization: tenantOrgId, + email: em, + eventMemberships: { $elemMatch: { event: new mongoose.Types.ObjectId(String(eventId)) } }, + }; +} + +function membershipGuestQuery(tenantOrgId, eventId, guestToken) { + const gt = String(guestToken || '').trim(); + return { + tenantOrganization: tenantOrgId, + eventMemberships: { + $elemMatch: { + event: new mongoose.Types.ObjectId(String(eventId)), + guestToken: gt, + }, + }, + }; +} + +function baseQueryForEvent(eventId) { + return { + eventMemberships: { $elemMatch: { event: new mongoose.Types.ObjectId(String(eventId)) } }, + }; +} + +module.exports = { + getMembership, + participantInEvent, + flattenParticipantForEvent, + membershipEmailQuery, + membershipGuestQuery, + baseQueryForEvent, +}; diff --git a/src/utils/paymentCrypto.js b/src/utils/paymentCrypto.js new file mode 100644 index 0000000..abb9303 --- /dev/null +++ b/src/utils/paymentCrypto.js @@ -0,0 +1,41 @@ +const crypto = require('crypto'); + +const ALGO = 'aes-256-gcm'; +const IV_LEN = 16; +const AUTH_TAG_LEN = 16; + +function deriveKey() { + const raw = process.env.PAYMENT_CREDENTIALS_KEY || process.env.JWT_SECRET || 'dev-payment-creds'; + return crypto.createHash('sha256').update(String(raw)).digest(); +} + +/** @param {string} plain */ +function encryptString(plain) { + const iv = crypto.randomBytes(IV_LEN); + const cipher = crypto.createCipheriv(ALGO, deriveKey(), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString('base64'); +} + +/** @param {string} b64 */ +function decryptString(b64) { + const buf = Buffer.from(b64, 'base64'); + if (buf.length < IV_LEN + AUTH_TAG_LEN + 1) throw new Error('Invalid ciphertext'); + const iv = buf.subarray(0, IV_LEN); + const tag = buf.subarray(IV_LEN, IV_LEN + AUTH_TAG_LEN); + const enc = buf.subarray(IV_LEN + AUTH_TAG_LEN); + const decipher = crypto.createDecipheriv(ALGO, deriveKey(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8'); +} + +function encryptObject(obj) { + return encryptString(JSON.stringify(obj)); +} + +function decryptObject(b64) { + return JSON.parse(decryptString(b64)); +} + +module.exports = { encryptString, decryptString, encryptObject, decryptObject }; diff --git a/src/utils/publishFolderAvailability.js b/src/utils/publishFolderAvailability.js new file mode 100644 index 0000000..d2e71e7 --- /dev/null +++ b/src/utils/publishFolderAvailability.js @@ -0,0 +1,47 @@ +const Event = require('../models/Event'); +const EventMicrosite = require('../models/EventMicrosite'); + +/** + * Normalize URL slug / static folder segment (matches website settings behavior). + * @param {string} raw + * @returns {string} + */ +function normalizePublishFolder(raw) { + return String(raw || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, ''); +} + +/** + * Globally unique publish folder: no other EventMicrosite, and no other Event's legacy website.folder. + * @param {string} folder normalized + * @param {string} eventId + * @param {string} [excludeMicrositeId] + * @returns {Promise<{ available: boolean, message?: string }>} + */ +async function publishFolderAvailability(folder, eventId, excludeMicrositeId) { + if (!folder) { + return { available: false, message: 'Folder is required' }; + } + const msFilter = { publishFolder: folder }; + if (excludeMicrositeId) { + msFilter._id = { $ne: excludeMicrositeId }; + } + const takenMs = await EventMicrosite.findOne(msFilter).select('_id').lean(); + if (takenMs) { + return { available: false, message: 'This folder is already in use' }; + } + const legacyOther = await Event.findOne({ + 'website.folder': folder, + _id: { $ne: eventId }, + }) + .select('_id') + .lean(); + if (legacyOther) { + return { available: false, message: 'Another event already uses this website folder' }; + } + return { available: true }; +} + +module.exports = { normalizePublishFolder, publishFolderAvailability }; diff --git a/src/utils/quizPollFirebaseSync.js b/src/utils/quizPollFirebaseSync.js new file mode 100644 index 0000000..dac25a4 --- /dev/null +++ b/src/utils/quizPollFirebaseSync.js @@ -0,0 +1,140 @@ +const { getFirebaseDb } = require('./firebase'); + +/** @param {unknown} id */ +function toIdString(id) { + if (id == null) return ''; + if (typeof id === 'object' && id !== null && typeof id.toString === 'function') { + return String(id); + } + return String(id); +} + +/** + * Settings for RTDB: visibility fields for visitor apps (`displayMode`, `status`, `isLive`, + * schedule), dates → ISO strings. Drops legacy `isActive` after mapping to `isLive`. + * @param {object | undefined} settings + */ +function normalizeSettings(settings) { + if (!settings || typeof settings !== 'object') return {}; + const s = { ...settings }; + const isLive = + typeof s.isLive === 'boolean' + ? s.isLive + : typeof s.isActive === 'boolean' + ? s.isActive + : false; + delete s.isActive; + s.isLive = isLive; + if (s.startTime != null) { + s.startTime = s.startTime instanceof Date ? s.startTime.toISOString() : String(s.startTime); + } + if (s.endTime != null) { + s.endTime = s.endTime instanceof Date ? s.endTime.toISOString() : String(s.endTime); + } + if (s.displayMode != null) s.displayMode = String(s.displayMode).trim(); + if (s.status != null) s.status = String(s.status).trim(); + return s; +} + +/** + * One option: _id always string (never ObjectId buffer). + * @param {object} opt + * @param {number} index + */ +function mapOptionForRtdb(opt, index) { + const o = opt && typeof opt.toObject === 'function' ? opt.toObject() : { ...opt }; + const id = toIdString(o._id) || `opt-${index}`; + return { + _id: id, + text: o.text != null ? o.text : '', + isCorrect: Boolean(o.isCorrect), + ...(o.imageUrl ? { imageUrl: o.imageUrl } : {}), + }; +} + +/** + * One question: _id string; nested options with string _id. + * @param {object} q + * @param {number} index + */ +function mapQuestionForRtdb(q, index) { + const o = q && typeof q.toObject === 'function' ? q.toObject() : { ...q }; + const id = toIdString(o._id) || `q-${index}`; + const out = { + _id: id, + question: o.question != null ? o.question : '', + type: o.type != null ? o.type : 'single', + options: (o.options || []).map((opt, j) => mapOptionForRtdb(opt, j)), + }; + if (o.imageUrl) out.imageUrl = o.imageUrl; + if (o.points != null) out.points = o.points; + if (o.required != null) out.required = o.required; + return out; +} + +/** + * Payload written to Firebase RTDB — only fields needed for live clients. + * All _id values are strings (never BSON / Buffer). + * @param {import('mongoose').Document | object} doc + */ +function quizPollToRtdbPayload(doc) { + const o = doc && typeof doc.toObject === 'function' ? doc.toObject({ depopulate: true }) : { ...doc }; + return { + _id: toIdString(o._id), + title: o.title != null ? o.title : '', + type: o.type != null ? o.type : 'poll', + settings: normalizeSettings(o.settings), + questions: (o.questions || []).map((q, i) => mapQuestionForRtdb(q, i)), + }; +} + +/** + * Upsert quiz/poll under sessions/{sessionId}/quizPolls/{quizPollId} + * (Audience questions live under sessions/{sessionId}/questions/{questionId} — see sessionQuestionFirebaseSync.) + * @param {string | null | undefined} previousSessionId - if session changed, old node is removed + */ +async function syncQuizPollToFirebase(quizPollDoc, previousSessionId) { + const db = getFirebaseDb(); + if (!db) return; + + const raw = quizPollDoc && typeof quizPollDoc.toObject === 'function' ? quizPollDoc.toObject({ depopulate: true }) : { ...quizPollDoc }; + const id = toIdString(raw._id); + const sessionId = raw.session != null && raw.session !== '' ? toIdString(raw.session) : null; + + try { + if (previousSessionId && String(previousSessionId) !== String(sessionId || '')) { + await db.ref(`sessions/${previousSessionId}/quizPolls/${id}`).remove(); + } + + if (!sessionId) { + return; + } + + if (raw.deletedAt) { + await db.ref(`sessions/${sessionId}/quizPolls/${id}`).remove(); + return; + } + + const payload = quizPollToRtdbPayload(quizPollDoc); + await db.ref(`sessions/${sessionId}/quizPolls/${id}`).set(payload); + } catch (e) { + console.error('[firebase] syncQuizPollToFirebase failed:', e.message); + } +} + +/** Remove node when quiz/poll is soft-deleted or session no longer applies */ +async function removeQuizPollFromFirebase(sessionId, quizPollId) { + const db = getFirebaseDb(); + if (!db || !sessionId) return; + try { + await db.ref(`sessions/${sessionId}/quizPolls/${quizPollId}`).remove(); + } catch (e) { + console.error('[firebase] removeQuizPollFromFirebase failed:', e.message); + } +} + +module.exports = { + syncQuizPollToFirebase, + removeQuizPollFromFirebase, + quizPollToRtdbPayload, +}; diff --git a/src/utils/response.js b/src/utils/response.js new file mode 100644 index 0000000..ec716aa --- /dev/null +++ b/src/utils/response.js @@ -0,0 +1,19 @@ +const success = (data, message = 'Operation successful', statusCode = 200) => ({ + success: true, + data, + message, + timestamp: new Date().toISOString(), + statusCode, +}); + +const error = (code, message, details = []) => ({ + success: false, + error: { + code, + message, + details, + }, + timestamp: new Date().toISOString(), +}); + +module.exports = { success, error }; diff --git a/src/utils/runMicrositePublish.js b/src/utils/runMicrositePublish.js new file mode 100644 index 0000000..3894fb1 --- /dev/null +++ b/src/utils/runMicrositePublish.js @@ -0,0 +1,78 @@ +const { buildMicrositeHtml, templateToWebsiteBag } = require('./micrositeBuild'); + +/** + * @param {object} eventDoc Mongoose Event document + * @param {object} micrositeLean Plain object (EventMicrosite shape) + * @param {object} [requestPayload] `{ targets?: 'draft' | 'live' | 'both' }` + * @returns {Promise<{ data?: object, err?: { code: string, status: number, message: string } }>} + */ +async function runPublishToDisk(eventDoc, micrositeLean, requestPayload) { + const staticRoot = process.env.MICROSITE_STATIC_ROOT; + if (!staticRoot || !String(staticRoot).trim()) { + return { + err: { + code: 'NOT_CONFIGURED', + status: 503, + message: 'Microsite publish target is not configured (MICROSITE_STATIC_ROOT).', + }, + }; + } + const website = templateToWebsiteBag(micrositeLean); + const folder = (website.folder || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, ''); + if (!folder) { + return { + err: { + code: 'VALIDATION_ERROR', + status: 400, + message: 'Set a URL slug (publish folder) before publishing.', + }, + }; + } + const regUrl = typeof website.registrationUrl === 'string' ? website.registrationUrl.trim() : ''; + const base = (process.env.MICROSITE_PUBLIC_BASE_URL || 'https://web.eventbuzz.live').replace(/\/$/, ''); + const targets = requestPayload && requestPayload.targets; + const draftFolder = `${folder}_draft`; + + const runOnce = async (publishFolderOverride) => + buildMicrositeHtml(eventDoc, { + staticRoot: String(staticRoot).trim(), + registrationUrl: regUrl || undefined, + publishFolderOverride, + website, + sourcePageId: micrositeLean.sourcePageId, + }); + + if (targets === 'draft') { + const { diskPath, publishFolder } = await runOnce(draftFolder); + return { + data: { diskPath, publicUrl: `${base}/${publishFolder}/`, folder: publishFolder, targets: 'draft' }, + }; + } + if (targets === 'live') { + const { diskPath, publishFolder } = await runOnce(folder); + return { data: { diskPath, publicUrl: `${base}/${publishFolder}/`, folder: publishFolder, targets: 'live' } }; + } + if (targets === 'both') { + await runOnce(draftFolder); + const { diskPath, publishFolder } = await runOnce(folder); + return { + data: { + diskPath, + publicUrl: `${base}/${publishFolder}/`, + draftPublicUrl: `${base}/${draftFolder}/`, + folder: publishFolder, + draftFolder, + targets: 'both', + }, + }; + } + const { diskPath, publishFolder } = await buildMicrositeHtml(eventDoc, { + staticRoot: String(staticRoot).trim(), + registrationUrl: regUrl || undefined, + website, + sourcePageId: micrositeLean.sourcePageId, + }); + return { data: { diskPath, publicUrl: `${base}/${publishFolder}/`, folder: publishFolder } }; +} + +module.exports = { runPublishToDisk }; diff --git a/src/utils/s3Media.js b/src/utils/s3Media.js new file mode 100644 index 0000000..0bc7257 --- /dev/null +++ b/src/utils/s3Media.js @@ -0,0 +1,94 @@ +const { S3Client, PutObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3"); + +function getEnv(name, fallback) { + const v = process.env[name]; + if (v == null || String(v).trim() === "") return fallback; + return String(v).trim(); +} + +function sanitizeKeySegment(segment) { + // Allow S3 key path separators only via explicit splitting. + const s = String(segment).trim(); + return s.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +function sanitizeModule(moduleValue) { + const raw = String(moduleValue || "").trim(); + if (!raw) return "general"; + const parts = raw.split("/").map(p => sanitizeKeySegment(p)).filter(Boolean); + return parts.join("/"); +} + +function buildS3Key({ orgId, eventId, module, filename }) { + const safeOrgId = sanitizeKeySegment(orgId || "org"); + const safeEventId = sanitizeKeySegment(eventId || "event"); + const safeModule = sanitizeModule(module); + const safeFilename = sanitizeKeySegment(filename || "file"); + return `eventbuzz/${safeOrgId}/${safeEventId}/${safeModule}/${safeFilename}`; +} + +function cloudfrontUrlForKey(key) { + const base = getEnv("AWS_URL", "").replace(/\/+$/, ""); + if (!base) return key; // should never happen in prod + return `${base}/${key}`; +} + +let cachedS3Client = null; +function getS3Client() { + if (cachedS3Client) return cachedS3Client; + + const region = getEnv("AWS_DEFAULT_REGION", "ap-south-1"); + const accessKeyId = getEnv("AWS_ACCESS_KEY_ID"); + const secretAccessKey = getEnv("AWS_SECRET_ACCESS_KEY"); + + cachedS3Client = new S3Client({ + region, + credentials: accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined, + }); + + return cachedS3Client; +} + +async function uploadBufferToS3AndGetCloudFrontUrl({ + buffer, + contentType, + orgId, + eventId, + module, + filename, +}) { + const bucket = getEnv("AWS_BUCKET"); + if (!bucket) throw new Error("AWS_BUCKET is not configured"); + + const key = buildS3Key({ orgId, eventId, module, filename }); + + await getS3Client().send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: buffer, + ContentType: contentType || "application/octet-stream", + }), + ); + + return { s3Key: key, url: cloudfrontUrlForKey(key) }; +} + +async function deleteS3ObjectByKey({ s3Key }) { + const bucket = getEnv("AWS_BUCKET"); + if (!bucket) return; + if (!s3Key) return; + + await getS3Client().send( + new DeleteObjectCommand({ + Bucket: bucket, + Key: s3Key, + }), + ); +} + +module.exports = { + uploadBufferToS3AndGetCloudFrontUrl, + deleteS3ObjectByKey, +}; + diff --git a/src/utils/sessionQuestionFirebaseSync.js b/src/utils/sessionQuestionFirebaseSync.js new file mode 100644 index 0000000..475f2b8 --- /dev/null +++ b/src/utils/sessionQuestionFirebaseSync.js @@ -0,0 +1,86 @@ +const { getFirebaseDb } = require('./firebase'); + +/** @param {unknown} id */ +function toIdString(id) { + if (id == null) return ''; + if (typeof id === 'object' && id !== null && typeof id.toString === 'function') { + return String(id); + } + return String(id); +} + +/** @param {object | null | undefined} p */ +function participantDisplayName(p) { + if (!p || typeof p !== 'object') return 'Anonymous'; + if (p.displayName && String(p.displayName).trim()) return String(p.displayName).trim(); + const n = [p.firstName, p.lastName].filter(Boolean).join(' ').trim(); + if (n) return n; + if (p.email && String(p.email).trim()) return String(p.email).split('@')[0] || 'Guest'; + return 'Guest'; +} + +/** + * @param {object} doc - lean or plain { _id, event, session, question, isAnonymous, participant, upvotes, downvotes, createdAt, status } + */ +function questionToRtdbPayload(doc) { + const o = doc && typeof doc === 'object' ? doc : {}; + let authorName = 'Anonymous'; + if (!o.isAnonymous) { + authorName = participantDisplayName(o.participant); + } + return { + _id: toIdString(o._id), + event: toIdString(o.event), + question: o.question != null ? String(o.question) : '', + authorName, + isAnonymous: Boolean(o.isAnonymous), + upvotes: typeof o.upvotes === 'number' ? o.upvotes : 0, + downvotes: typeof o.downvotes === 'number' ? o.downvotes : 0, + status: o.status != null ? o.status : 'pending', + createdAt: + o.createdAt instanceof Date + ? o.createdAt.toISOString() + : o.createdAt != null + ? String(o.createdAt) + : new Date().toISOString(), + }; +} + +/** + * Upsert audience question under sessions/{sessionId}/questions/{questionId} + * @param {object} doc - populated participant recommended + */ +async function syncSessionQuestionToFirebase(doc) { + const db = getFirebaseDb(); + if (!db || !doc) return; + + const sessionId = doc.session != null && doc.session !== '' ? toIdString(doc.session) : ''; + if (!sessionId) return; + + const id = toIdString(doc._id); + if (!id) return; + + try { + const payload = questionToRtdbPayload(doc); + await db.ref(`sessions/${sessionId}/questions/${id}`).set(payload); + } catch (e) { + console.error('[firebase] syncSessionQuestionToFirebase failed:', e.message); + } +} + +/** @param {string} sessionId @param {string} questionId */ +async function removeSessionQuestionFromFirebase(sessionId, questionId) { + const db = getFirebaseDb(); + if (!db || !sessionId || !questionId) return; + try { + await db.ref(`sessions/${sessionId}/questions/${questionId}`).remove(); + } catch (e) { + console.error('[firebase] removeSessionQuestionFromFirebase failed:', e.message); + } +} + +module.exports = { + syncSessionQuestionToFirebase, + removeSessionQuestionFromFirebase, + questionToRtdbPayload, +}; diff --git a/src/utils/tenantListScope.js b/src/utils/tenantListScope.js new file mode 100644 index 0000000..389aadb --- /dev/null +++ b/src/utils/tenantListScope.js @@ -0,0 +1,40 @@ +const mongoose = require('mongoose'); + +/** + * Resolve the organisation ObjectId list/count APIs must scope to. + * Does not return an "all tenants" scope: super_admin must pass queryOrganization + * or have user.organization set. + */ +function resolveScopedOrganizationId(user, queryOrganization) { + const userOrgRaw = user.organization?._id ?? user.organization; + if (user.role === 'super_admin') { + const raw = queryOrganization ?? userOrgRaw; + if (raw == null || raw === '') { + return { + error: { + code: 'VALIDATION_ERROR', + message: + 'List is scoped by organisation. Pass ?organization= or set a default organisation on the account.', + }, + }; + } + const s = String(raw); + if (!mongoose.Types.ObjectId.isValid(s)) { + return { error: { code: 'VALIDATION_ERROR', message: 'Invalid organization id' } }; + } + return { organizationId: new mongoose.Types.ObjectId(s) }; + } + + if (userOrgRaw == null || userOrgRaw === '') { + return { error: { code: 'VALIDATION_ERROR', message: 'Your account has no organisation' } }; + } + const s = String(userOrgRaw); + if (!mongoose.Types.ObjectId.isValid(s)) { + return { error: { code: 'VALIDATION_ERROR', message: 'Invalid user organisation' } }; + } + return { + organizationId: userOrgRaw instanceof mongoose.Types.ObjectId ? userOrgRaw : new mongoose.Types.ObjectId(s), + }; +} + +module.exports = { resolveScopedOrganizationId }; diff --git a/src/utils/websiteHtml.js b/src/utils/websiteHtml.js new file mode 100644 index 0000000..d63f18b --- /dev/null +++ b/src/utils/websiteHtml.js @@ -0,0 +1,686 @@ +/** + * Build Bootstrap 5 HTML for an event page. + * Output is strict Bootstrap 5 (classes only) so templates can apply color schemes and structure. + * @param {Object} page - EventPage document with sections array + * @param {Object} event - Event document (name, etc.) + * @param {Object} options - { agendaItems: [], speakers: [], registrationUrl: '', layoutTemplate: 'conference', event } + * @returns {string} Full HTML document + */ +function buildSectionsHtml(page, event, options = {}) { + const eventName = event?.name || 'Event'; + const layoutTemplate = options.layoutTemplate || 'conference'; + const sections = (page?.sections || []).slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + const opts = { ...options, layoutTemplate, event }; + const bodyParts = sections.map((s) => renderSection(s, eventName, opts)).filter(Boolean); + return bodyParts.join('\n'); +} + +function buildPageHtml(page, event, options = {}) { + const eventName = event?.name || 'Event'; + const pageTitle = page?.title || eventName; + const bodyHtml = buildSectionsHtml(page, event, options); + + return ` + + + + + ${escapeHtml(pageTitle)} + + + +${bodyHtml} + + +`; +} + +function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderSection(section, eventName, options) { + const layout = options.layoutTemplate || 'conference'; + const c = section.config || {}; + switch (section.type) { + case 'header': + return renderHeader(c, eventName, layout); + case 'banner': + return renderBanner(c, layout); + case 'contact_form': + return renderContactForm(c, layout); + case 'agenda': + return renderAgenda(c, options.agendaItems, layout); + case 'register_links': + return renderRegisterLinks(c, options.registrationUrl, layout); + case 'speakers': + return renderSpeakers(c, options.speakers, layout); + case 'footer': + return renderFooter(c, eventName, layout); + case 'sponsors': + return renderSponsors(c, layout); + case 'cta': + return renderCta(c, layout); + case 'custom_html': + return renderCustomHtml(c, layout); + case 'countdown': + return renderCountdown(c, layout); + case 'gallery': + return renderGallery(c, layout); + case 'about': + return renderAboutSection(c, layout); + case 'venue': + return renderVenue(c, layout); + case 'faq': + return renderFaq(c, layout); + case 'tickets': + return renderTickets(c, layout); + default: + return ''; + } +} + +function renderHeader(config, eventName, layout) { + if (layout === 'conference') return renderHeaderConference(config, eventName); + const logoText = config.logoText != null ? config.logoText : eventName; + const menuItems = config.menuItems || []; + const items = menuItems + .map((item) => ``) + .join('\n '); + return ` + `; +} + +function renderHeaderConference(config, eventName) { + const logoText = config.logoText != null ? config.logoText : eventName; + const logoUrl = config.logoUrl != null ? String(config.logoUrl).trim() : ''; + const menuItems = config.menuItems || []; + const items = menuItems + .map((item) => { + const label = escapeHtml(item.label || ''); + const url = escapeHtml(item.url || '#'); + const isReg = String(item.label || '').toLowerCase() === 'register'; + const linkClass = isReg + ? 'nav-link btn btn-sm btn-outline-light ms-lg-2 mt-2 mt-lg-0 align-self-lg-center' + : 'nav-link'; + return ``; + }) + .join('\n '); + const brand = logoUrl + ? `` + : `${escapeHtml(logoText)}`; + return ` +
+ +
`; +} + +function renderBanner(config, layout) { + if (layout === 'conference') return renderBannerConference(config); + const headline = config.headline != null ? config.headline : 'Welcome'; + const subtext = config.subtext != null ? config.subtext : ''; + const ctaText = config.ctaText; + const ctaUrl = config.ctaUrl || '#'; + const bgClass = config.backgroundClass || 'bg-primary'; + const ctaHtml = ctaText ? `${escapeHtml(ctaText)}` : ''; + return ` +
+
+

${escapeHtml(headline)}

+ ${subtext ? `

${escapeHtml(subtext)}

` : ''} + ${ctaHtml} +
+
`; +} + +function renderBannerConference(config) { + const headline = config.headline != null ? config.headline : 'Welcome'; + const subtext = config.subtext != null ? config.subtext : ''; + const heroMetaLine = config.heroMetaLine != null ? String(config.heroMetaLine).trim() : ''; + const ctaText = config.ctaText; + const ctaUrl = config.ctaUrl || '#'; + const bgImage = config.backgroundImage != null ? String(config.backgroundImage).trim() : ''; + const hasImg = !!bgImage; + const ctaHtml = ctaText ? `${escapeHtml(ctaText)}` : ''; + const secText = config.secondaryCtaText != null ? String(config.secondaryCtaText).trim() : ''; + const secUrl = config.secondaryCtaUrl != null ? String(config.secondaryCtaUrl).trim() : '#schedule'; + const secHtml = secText ? `${escapeHtml(secText)}` : ''; + const metaHtml = heroMetaLine + ? `
${escapeHtml(heroMetaLine)}
` + : ''; + const bgAttr = hasImg ? ` style="background-image:url("${escapeHtml(bgImage)}")"` : ''; + return ` +
+
+
+
+

${escapeHtml(headline)}

+ ${metaHtml} + ${subtext ? `

${escapeHtml(subtext)}

` : ''} + ${(ctaHtml || secHtml) ? `
${ctaHtml}${secHtml}
` : ''} +
+
`; +} + +function renderContactForm(config, layout) { + const title = config.title != null ? config.title : 'Contact Us'; + const submitLabel = config.submitLabel != null ? config.submitLabel : 'Send'; + const sectClass = layout === 'conference' ? 'py-5 eb-section-contact' : 'py-5'; + return ` +
+
+

${escapeHtml(title)}

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
`; +} + +function renderAgenda(config, agendaItems, layout) { + if (layout === 'conference') return renderAgendaConference(config, agendaItems); + const title = config.title != null ? config.title : 'Agenda'; + const items = agendaItems && agendaItems.length ? agendaItems : [{ title: 'Session 1', time: '9:00 AM', track: 'Main' }]; + const listItems = items + .map((item) => `
  • +
    +
    ${escapeHtml(item.title || '')}
    + ${item.track ? `${escapeHtml(item.track)}` : ''} +
    + ${escapeHtml(item.time || '')} +
  • `) + .join('\n '); + return ` +
    +
    +

    ${escapeHtml(title)}

    +
      + ${listItems} +
    +
    +
    `; +} + +function renderAgendaConference(config, agendaItems) { + const title = config.title != null ? config.title : 'Schedule'; + const items = agendaItems && agendaItems.length ? agendaItems : [{ title: 'Session 1', time: '9:00 AM', track: 'Main' }]; + const rows = items + .map((item) => { + const hasSp = item.speakerName && String(item.speakerName).trim(); + const itemClass = hasSp ? 'eb-schedule-item item-talk py-3' : 'eb-schedule-item item-other py-3'; + const profileHtml = hasSp && item.speakerPhotoUrl + ? `
    ` + : ''; + const spNameHtml = hasSp ? `
    ${escapeHtml(item.speakerName)}
    ` : ''; + const descHtml = item.description ? `

    ${escapeHtml(item.description)}

    ` : ''; + if (!hasSp) { + return `
    +
    +
    +
    ${escapeHtml(item.time || '')}
    +
    +
    +

    ${escapeHtml(item.title || '')}

    + ${item.track ? `
    ${escapeHtml(item.track)}
    ` : ''} + ${descHtml} +
    +
    +
    `; + } + return `
    +
    +
    +
    ${escapeHtml(item.time || '')}
    + ${profileHtml} + ${spNameHtml} +
    +
    +

    ${escapeHtml(item.title || '')}

    + ${item.track ? `
    ${escapeHtml(item.track)}
    ` : ''} + ${descHtml} +
    +
    +
    `; + }) + .join('\n '); + return ` +
    +
    +

    ${escapeHtml(title)}

    +
    + ${rows} +
    + +
    +
    `; +} + +function renderRegisterLinks(config, registrationUrl, layout) { + const title = config.title != null ? config.title : 'Register'; + const primaryLabel = config.primaryLabel != null ? config.primaryLabel : 'Register Now'; + const primaryUrl = config.primaryUrl || registrationUrl || '#'; + const secondaryLabel = config.secondaryLabel; + const secondaryUrl = config.secondaryUrl || '#'; + const secondaryHtml = secondaryLabel ? `${escapeHtml(secondaryLabel)}` : ''; + const hClass = layout === 'conference' ? 'eb-section-heading' : ''; + return ` +
    +
    +

    ${escapeHtml(title)}

    + ${escapeHtml(primaryLabel)} + ${secondaryHtml} +
    +
    `; +} + +function renderSpeakers(config, speakers, layout) { + if (layout === 'conference') return renderSpeakersConference(config, speakers); + const title = config.title != null ? config.title : 'Speakers'; + const limit = config.limit || 12; + const list = (speakers || []).slice(0, limit); + const cards = list.length + ? list + .map((s) => `
    +
    + ${s.photoUrl ? `${escapeHtml(s.name || '')}` : '
    '} +
    +
    ${escapeHtml(s.name || '')}
    + ${s.designation ? `

    ${escapeHtml(s.designation)}

    ` : ''} +
    +
    +
    `) + .join('\n ') + : '
    No speakers added yet.
    '; + return ` +
    +
    +

    ${escapeHtml(title)}

    +
    + ${cards} +
    +
    +
    `; +} + +function renderSpeakersConference(config, speakers) { + const title = config.title != null ? config.title : 'Speakers'; + const limit = config.limit || 12; + const list = (speakers || []).slice(0, limit); + const cards = list.length + ? list + .map((s) => `
    +
    + ${s.photoUrl ? `${escapeHtml(s.name || '')}` : '
    '} +
    +
    ${escapeHtml(s.name || '')}
    + ${s.designation ? `

    ${escapeHtml(s.designation)}

    ` : ''} +
    +
    +
    `) + .join('\n ') + : '
    No speakers added yet.
    '; + return ` +
    +
    +

    ${escapeHtml(title)}

    +
    + ${cards} +
    +
    +
    `; +} + +function renderFooter(config, eventName, layout) { + if (layout === 'conference') return renderFooterConference(); + const copyrightText = config.copyrightText != null ? config.copyrightText : `© ${new Date().getFullYear()} ${eventName}. All rights reserved.`; + const links = config.links || []; + const linksHtml = links + .map((link) => `${escapeHtml(link.label || '')}`) + .join('\n '); + return ` +
    +
    +
    + ${escapeHtml(copyrightText)} +
    ${linksHtml}
    +
    +
    +
    `; +} + +function renderFooterConference(/* shell includes static marketing footer */) { + return ''; +} + +function renderSponsors(config, layout) { + const title = config.title != null ? config.title : 'Sponsors'; + const sponsors = config.sponsors || []; + const partnerCtaLabel = config.partnerCtaLabel != null ? String(config.partnerCtaLabel).trim() : ''; + const partnerCtaUrl = config.partnerCtaUrl != null ? String(config.partnerCtaUrl).trim() : '#'; + if (layout === 'conference') { + const cells = (sponsors || []).length + ? sponsors + .map( + (s) => + ``, + ) + .join('\n ') + : Array.from({ length: 6 }, (_, i) => `
    ${escapeHtml(`Sponsor ${String.fromCharCode(65 + i)}`)}
    `).join('\n '); + const partnerBtn = partnerCtaLabel + ? `` + : ''; + return ` +
    +
    +

    ${escapeHtml(title)}

    +
    + ${cells} +
    + ${partnerBtn} +
    +
    `; + } + const items = sponsors + .map((s) => ``) + .join('\n '); + const row = items || '
    No sponsors added yet.
    '; + return ` +
    +
    +

    ${escapeHtml(title)}

    +
    + ${row} +
    +
    +
    `; +} + +function renderCta(config, layout) { + const headline = config.headline != null ? config.headline : 'Join us'; + const subtext = config.subtext != null ? config.subtext : ''; + const buttonLabel = config.buttonLabel != null ? config.buttonLabel : 'Register'; + const buttonUrl = config.buttonUrl || '#'; + const sectClass = layout === 'conference' ? 'py-5 bg-primary text-white eb-conference-cta' : 'py-5 bg-primary text-white'; + const hClass = layout === 'conference' ? 'eb-section-heading text-white' : ''; + return ` +
    +
    +

    ${escapeHtml(headline)}

    + ${subtext ? `

    ${escapeHtml(subtext)}

    ` : ''} + ${escapeHtml(buttonLabel)} +
    +
    `; +} + +function renderCustomHtml(config, layout) { + const html = config.html != null ? String(config.html) : ''; + if (!html.trim()) return ''; + const sectClass = layout === 'conference' ? 'py-5 eb-about-section eb-section-light' : 'py-3'; + return ` +
    +
    +
    ${html}
    +
    +
    `; +} + +function renderCountdown(config, layout) { + if (layout !== 'conference') return ''; + const headingLine = config.headingLine != null ? String(config.headingLine).trim() : ''; + return ` +
    +
    +

    ${escapeHtml(headingLine)}

    +

    …

    +
    +
    `; +} + +function renderGallery(config, layout) { + if (layout !== 'conference') return ''; + const title = config.title != null ? config.title : 'Gallery'; + const images = Array.isArray(config.images) && config.images.length + ? config.images + : [{ url: '', alt: '' }]; + const cols = images + .slice(0, 4) + .map((im) => { + const u = im.url != null ? String(im.url) : ''; + const alt = escapeHtml(im.alt != null ? String(im.alt) : ''); + return `
    +
    + ${alt} +
    +
    `; + }) + .join('\n '); + return ` +
    +
    +

    ${escapeHtml(title)}

    +
    + ${cols} +
    +
    +
    `; +} + +function renderAboutSection(config, layout) { + if (layout !== 'conference') return ''; + const title = config.title != null ? config.title : 'About'; + const lead = config.lead != null ? escapeHtml(String(config.lead)) : ''; + const bulletsTitle = config.bulletsTitle != null ? String(config.bulletsTitle) : ''; + const bullets = Array.isArray(config.bullets) ? config.bullets : []; + const ctaLabel = config.ctaLabel != null ? String(config.ctaLabel) : ''; + const ctaUrl = config.ctaUrl != null ? String(config.ctaUrl) : '#register'; + const listItems = bullets + .map((b) => `
  • ${typeof b === 'string' ? escapeHtml(b) : escapeHtml(String(b))}
  • `) + .join('\n '); + const ctaHtml = ctaLabel ? `` : ''; + return ` +
    +
    +

    ${escapeHtml(title)}

    + ${lead ? `

    ${lead}

    ` : ''} + ${bulletsTitle ? `

    ${escapeHtml(bulletsTitle)}

    ` : ''} + ${listItems ? `
      ${listItems}
    ` : ''} + ${ctaHtml} +
    +
    `; +} + +function renderVenue(config, layout) { + if (layout !== 'conference') return ''; + const title = config.title != null ? config.title : 'Venue'; + const venueName = config.venueName != null ? String(config.venueName) : 'Venue'; + const introPlain = config.intro != null ? String(config.intro) : (config.introHtml != null ? String(config.introHtml) : ''); + const introHtml = introPlain ? escapeHtml(introPlain) : ''; + const mapEmbedUrl = config.mapEmbedUrl != null ? String(config.mapEmbedUrl).trim() : ''; + const transportCards = Array.isArray(config.transportCards) ? config.transportCards : []; + const cardsHtml = transportCards + .map((card) => { + const icon = card.icon != null ? String(card.icon) : 'geo-alt'; + const ct = escapeHtml(card.title != null ? String(card.title) : ''); + const txt = escapeHtml(card.text != null ? String(card.text) : ''); + return `
    +
    +

    ${ct}

    +

    ${txt}

    +
    +
    `; + }) + .join('\n '); + const iframeSrc = mapEmbedUrl ? escapeHtml(mapEmbedUrl) : ''; + const mapHtml = iframeSrc + ? `
    + +
    ` + : ''; + return ` +
    +
    +

    ${escapeHtml(title)}

    +
    +
    +

    ${escapeHtml(venueName)}

    + ${introHtml ? `

    ${introHtml}

    ` : ''} +
    ${cardsHtml}
    +
    +
    ${mapHtml}
    +
    +
    +
    `; +} + +function renderFaq(config, layout) { + if (layout !== 'conference') return ''; + const title = config.title != null ? config.title : 'FAQ'; + const items = Array.isArray(config.items) ? config.items : []; + const email = config.contactEmail != null ? String(config.contactEmail).trim() : ''; + const acc = items + .map((it, i) => { + const q = escapeHtml(it.question != null ? String(it.question) : ''); + const a = it.answer != null ? String(it.answer) : ''; + const collapseId = `faqDyn${i + 1}`; + const show = i === 0 ? ' show' : ''; + const expanded = i === 0 ? 'true' : 'false'; + const btnClass = i === 0 ? 'accordion-button' : 'accordion-button collapsed'; + return `
    +

    + +

    +
    +
    ${escapeHtml(a)}
    +
    +
    `; + }) + .join('\n '); + const mailHtml = email + ? `

    Still have questions? Email the team

    ` + : ''; + return ` +
    +
    +

    ${escapeHtml(title)}

    +
    + ${acc} +
    + ${mailHtml} +
    +
    `; +} + +function renderTickets(config, layout) { + if (layout !== 'conference') return ''; + const title = config.title != null ? config.title : 'Tickets'; + const intro = config.intro != null ? String(config.intro) : ''; + const tiers = Array.isArray(config.tiers) ? config.tiers : []; + const includedTitle = config.includedTitle != null ? String(config.includedTitle) : ''; + const includedBullets = Array.isArray(config.includedBullets) ? config.includedBullets : []; + const tierCols = tiers + .map((tier) => { + const tname = escapeHtml(tier.name != null ? String(tier.name) : ''); + const price = escapeHtml(tier.price != null ? String(tier.price) : ''); + const blurb = escapeHtml(tier.blurb != null ? String(tier.blurb) : ''); + const featured = !!tier.featured; + const btnLabel = escapeHtml(tier.buttonLabel != null ? String(tier.buttonLabel) : 'Choose'); + const btnUrl = escapeHtml(tier.buttonUrl != null ? String(tier.buttonUrl) : '#'); + const cardClass = featured ? 'card card-special rounded-0 border-0 h-100' : 'card rounded-0 border-0 shadow-sm h-100'; + const btnClass = featured ? 'btn btn-primary' : 'btn btn-outline-primary'; + return `
    +
    +
    +

    ${tname}

    +
    +
    +
    $${price}
    +

    ${blurb}

    +
    + +
    +
    `; + }) + .join('\n '); + const incList = includedBullets + .map((line) => `
  • ${escapeHtml(String(line))}
  • `) + .join('\n '); + const incBlock = + includedTitle && incList + ? `
    +

    ${escapeHtml(includedTitle)}

    +
      ${incList}
    +
    ` + : ''; + return ` +
    +
    +

    ${escapeHtml(title)}

    + ${intro ? `

    ${escapeHtml(intro)}

    ` : ''} +
    + ${tierCols} +
    + ${incBlock} +
    +
    `; +} + +module.exports = { buildPageHtml, buildSectionsHtml, escapeHtml };