commit 3d381944d2acc598546a9cd126cbe559cece9407 Author: joshua Date: Thu Jul 23 15:26:47 2026 +0200 Initial commit Next.js + Express event management app for Hope Family Church. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..176d8f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# dependencies +node_modules/ + +# env files (never commit real secrets — .env.example files are fine) +.env +.env.* +!.env.example + +# IDE +.idea/ +.vscode/ + +# next.js +.next/ +out/ + +# build output +dist/ +build/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.log + +# typescript +*.tsbuildinfo + +# OS +.DS_Store +Thumbs.db + +# misc scratch / generated files +temp/ +backend/public/uploads/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..94d9844 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# Hope Events Platform + +Hope Events is a full-stack event management platform that allows users to browse events, register for them, make payments, and manage their tickets. + +## Project Structure + +The project is divided into two main parts: + +- Backend: Node.js + Express + Prisma + PostgreSQL +- Frontend: Next.js (React) + Tailwind CSS + +### Backend + +The backend provides a RESTful API for managing users, events, registrations, payments, uploads, and tickets. It includes: + +- JWT-based authentication and role-based authorization +- Event and registration management +- Payment integration with Yoco (checkout + webhooks) +- Ticket generation (PDF + QR) and email delivery +- Static uploads for event images + +Documentation: see Backend API Documentation and Backend README for environment setup and endpoints. +- Backend API Documentation: ./backend/API_DOCUMENTATION.md +- Backend README: ./backend/README.md + +### Frontend + +The frontend is a Next.js app that provides: + +- User authentication (login, register) +- Browse/search events and view details +- Register for events and pay via Yoco +- Manage and view tickets + +Documentation: see Frontend README for environment and scripts. +- Frontend README: ./frontend/README.md + +## Getting Started (Local Development) + +### Prerequisites + +- Node.js 18+ (recommended) +- npm or yarn +- PostgreSQL database + +### 1) Install dependencies + +- Backend + - cd backend + - npm install + +- Frontend + - cd ../frontend + - npm install + +### 2) Configure environment variables + +- Backend: create backend/.env and set required variables (see backend/README.md). At minimum: + - DATABASE_URL + - JWT_SECRET + - PORT (optional; defaults to 3000 if not set) + - YOCO_SECRET_KEY (for payments) + - YOCO_WEBHOOK_SECRET (to verify webhooks) + - EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM (for ticket emails) + - FRONTEND_URL or APP_BASE_URL (used in email links) + +- Frontend: create frontend/.env.local and set + - NEXT_PUBLIC_API_URL=http://localhost: + Example for default backend: NEXT_PUBLIC_API_URL=http://localhost:5000 + +- Organisation name, logo, brand colour, and notification emails are configured via the admin panel (Admin → Site Settings) and do not need to be set in .env files. + +### 3) Run the apps + +- Start backend (development): + - cd backend + - npm run dev + - By default runs at http://localhost:3000 (or the PORT you set) + +- Start frontend (development): + - cd ../frontend + - npm run dev + - Next.js dev server runs at http://localhost:3000 by default; if it conflicts with backend, it will pick another port (e.g., 3001). Ensure NEXT_PUBLIC_API_URL points to the backend port. + +## Deployment + +### Backend Deployment + +1. Provision a PostgreSQL database and set DATABASE_URL. +2. Set all required environment variables (see backend/README.md). +3. Run Prisma migrations (if applicable). +4. Start the server with: npm start (or a process manager like PM2). +5. Expose the webhook endpoint /api/webhooks/yoco publicly and configure the YOCO webhook to point to it. +6. Ensure the uploads directory public/uploads exists and is writable. + +### Frontend Deployment + +1. Build the frontend: + - cd frontend + - npm run build +2. Run with npm start (Next.js) on your server, or deploy to a platform like Vercel. +3. Ensure NEXT_PUBLIC_API_URL points to the public URL of your backend (including port if not 80/443). + +### Ports and URLs + +- Backend default port is 3000 unless overridden by PORT. +- Frontend dev server commonly uses 3000; set NEXT_PUBLIC_API_URL to avoid clashes (e.g., backend 3000, frontend 3001). + +## Useful Links + +- Backend API Documentation: ./backend/API_DOCUMENTATION.md +- Backend README: ./backend/README.md +- Frontend README: ./frontend/README.md + +## Recent Changes + +### Sold-out handling, tiered stock warnings & UI polish + +**Backend** +- `GET /api/events` now computes and returns `isSoldOut: boolean` per event — true when every option with a stock limit (`stockLimit > 0`) is fully sold out. Events with only unlimited options are never sold out. +- `POST /api/settings/test-smtp` error responses now include a `raw` field (the original error message) alongside the human-readable `message`. Error code 530 (Microsoft "Client not authenticated to send mail") is now correctly mapped to the authentication-failure message. + +**Frontend** +- **Event listing** (`/events`): `EventCard` shows a disabled "Sold Out" button (red tint) when `isSoldOut` is true, replacing the Register button. Free events now show "Register" without the "- R0.00" suffix. +- **Event detail page** (`/events/[id]`): computes sold-out state from returned option `availableCount`; shows a disabled "Sold Out" button instead of the Register link. Options with price `0` display "Free" instead of "R0.00". +- **Registration page** (`/register/[eventId]`): shows a red "This event is sold out" banner and replaces the Register button with a disabled "Sold Out" button when all limited options are exhausted. +- **Tiered low-stock threshold**: the "X remaining" badge now uses capacity-based percentages — ≤ 50 tickets: 20 %; 51–200: 15 %; 201–1,000: 10 %; 1,000+: 5 %. Changed from `Math.ceil` to `Math.round` to prevent rounding-up inflation of the threshold. Fixed a React rendering bug where `stockLimit && stockLimit > 0 && ...` evaluated to the number `0` (which React renders as visible text) instead of a boolean — this caused "00" to appear next to options with unlimited stock (`stockLimit = 0`). +- **Event image upload**: the EventForm (used in the create modal) now shows a file upload button with inline preview alongside the URL input field. Files are uploaded to `POST /api/uploads/event-image` and the returned URL is set automatically. +- **Attachments during event creation**: step 6 ("Attachments") of the supervisor create-event wizard now lets you stage files. They are uploaded to `POST /api/events/:id/attachments` immediately after the event record is created, so attachments no longer require a separate edit session. +- **SMTP test details**: on failure the "Test connection" result now shows a collapsible "Show technical details" section exposing the raw SMTP error alongside the friendly message. `ApiError` in `src/lib/api.ts` now carries a `data` field with the full JSON response body. + + + +### Variants, stock limits, early-bird pricing & cancellation guard + +**Backend** +- **`OptionVariant` model**: event options can now have sub-variants (sizes, colours, ticket types — e.g. Adult/Child/VIP under a single "Ticket" option). Variants have an optional `price` override and their own `stockLimit`. +- **Stock limits**: `stockLimit` added to `EventOption` and `EarlyBirdTier`. A value of `0` means unlimited. All stock counts exclude cancelled registrations. +- **Early-bird concurrency handling**: once a Yoco checkout has been issued at an early-bird price, that price is honoured even if another user simultaneously exhausts the tier's stock. Stock-based forfeiture only applies at registration time (via `resolveOptionPrice`). Deadline expiry at payment time forfeits the price; stock races after the checkout is issued do not. +- **Early-bird stock forfeiture**: if a tier's deadline passes after registration but before payment, `refreshPricingForRegistration` (called before every Yoco checkout) re-evaluates the tier and updates `priceSnapshot`. If prices changed, `POST /api/payments/yoco-checkout` returns `{ priceUpdated: true, newTotal }` (HTTP 200) so the frontend can show a warning before retrying. +- **`priceSnapshot` / `appliedTierId`** on `RegistrationOption`: price and tier are snapshotted at registration creation. `computeRegistrationTotalDue` uses `priceSnapshot` as authoritative when present (legacy rows without a snapshot fall back to deadline-only re-evaluation). +- **Cancelled registration tickets**: `GET /api/tickets/mytickets` now excludes tickets from cancelled registrations at the database level. +- **Cancellation payment guard**: `DELETE /api/registrations/:id` blocks non-admins from cancelling a registration that has positive payments. Admins can cancel at any time. +- **Variant CRUD**: `POST /api/events/options/:id/variants`, `PUT /api/events/variants/:id`, `DELETE /api/events/variants/:id`. +- **Testing mode** (`NODE_ENV=testing`): behaves like development (permissive CORS, all debug routes) but validates required env vars (`DATABASE_URL`, `JWT_SECRET`) at startup and enables rate limiting. + +**Frontend** +- **Register page** (`/register/[eventId]`): variant picker — when an option has variants, shows each variant with its own qty control, stock badge (sold out / X remaining at ≤ 15 %), and price. Submission payload includes `variantId`. Early-bird notice shown when any active tier is present. +- **Event detail page** (`/events/[id]`): ticket section shows variant breakdown with "From R…" pricing, early-bird strikethrough, and sold-out/nearly-out badges. +- **User dashboard**: "Cancel registration" button appears in the registration modal when no payments have been made. Inline confirmation before the API call. +- **Pay page**: if the backend returns `priceUpdated: true`, an amber warning banner shows the new total and a dismiss button before the user can retry. +- **Supervisor events page** (`/dashboard/supervisor/events`): 6-step create/edit modal — "Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Attachments". In create mode, step tabs are not clickable (linear navigation only); in edit mode any step can be jumped to. Options in "Items & Pricing" are collapsible cards; Variants and Early-bird tiers are expandable sub-sections within each option. `/dashboard/supervisor/sections` now redirects to the Events page. +- **Legal pages**: Terms of Use updated with early-bird pricing terms, partial payment terms, and self-cancellation policy. Privacy Policy updated with data anonymisation details. + + + +### Site settings & first-time setup wizard +- **Admin → Site Settings** (`/dashboard/admin/settings`): organisation details, branding (colour + logo), notification emails, **SMTP email delivery**, and **legal page content** are stored in the database and editable at runtime. +- **Setup wizard** (`/setup`): on a fresh deployment (empty database) the platform automatically shows a wizard — organisation details, admin account creation, and branding. No manual DB seeding required. +- **`/api/settings`** (public): exposes public settings (org details, legal keys) for navbar and legal pages. +- **`/api/setup`** (public, one-time): creates the first admin account; blocked once any user exists. +- **Settings cache** (`settingsCache.js`): in-process 60-second cache with transparent AES-256-GCM decryption for sensitive keys. +- **SMTP via admin panel**: host, port, TLS, from address, username, and password are all configurable without touching `.env`. Password is stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). +- **Legal pages dynamic**: Terms of Use and Privacy Policy populate org name, contact email, website URL, operator name, Information Officer details, and effective date from the settings DB. +- **`ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, SMTP vars** no longer required in `.env` — all managed via the admin panel (env vars still work as fallbacks). + +### Admin dashboard improvements +- **User management** (`/dashboard/admin/users`): filtering (role, active status, text search) is now server-side so page size is always consistent; per-page selector (10/25/50/100); "Delete data" button anonymises a user's personal information via `POST /api/users/:id/anonymize`. +- **Registrations** (`/dashboard/admin/registrations`): event dropdown, status, and fuzzy text filters; expandable rows showing ticket options and form responses loaded on demand. + +### Forms page (`/dashboard/supervisor/forms`, `/dashboard/admin/forms`) +- **Print** opens a clean popup window (no site chrome) with one form response per A4 page — event name, attendee details, registration ID, and answers in a two-column grid. +- **User filter** replaced raw "User ID" text input with a searchable name/email/phone dropdown. +- **Event filter** includes "Include past events" and "Include inactive events" checkboxes. +- Filters in a responsive grid; dropdowns constrained to `max-w-xs`. + +### Ticket printing +- **At-the-door individual print**: individual ticket print now generates a proper A6 ticket with QR code, event title, date, type, holder name, and ID — no longer just a UUID. +- **Staff event-tickets page**: A4 print layout matches at-the-door (2 columns × 4 rows, 8 tickets per page). + +### Form builder & events +- **Form builder**: up/down reorder buttons; type badge (Text/Number/Date/etc.); separate quick-add buttons for Statement and Heading fields. +- **Early-bird tiers**: no manual order number — tiers ordered by list position; labelled Deadline and Price inputs. + +### Bulk messaging dropdowns +- Attendee selection dropdown constrained to `max-w-xs` / `w-64` panel on email-attendees and whatsapp-attendees pages. + +### Backend +- `GET /api/users` supports server-side `?search=`, `?role=`, `?isActive=` filtering. +- `POST /api/users/:id/anonymize` — erases personal data (admin only). +- `GET|PUT /api/settings`, `GET /api/settings/needs-setup`, `POST /api/setup` — settings and setup endpoints. +- `POST /api/uploads/logo` — logo upload (admin, or unauthenticated during first-time setup). +- `backend/src/utils/encryption.js` — AES-256-GCM encrypt/decrypt; key derived from `JWT_SECRET` via scrypt. +- SMTP transporter rebuilt dynamically when settings cache detects a config change — no restart required after updating SMTP settings. + +## License + +This project is licensed under the MIT License. \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..fec5d1e --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,53 @@ +# ─── Database ──────────────────────────────────────────────────────────────── +DATABASE_URL="postgresql://username:password@localhost:5432/hope_events" + +# ─── Auth ───────────────────────────────────────────────────────────────────── +JWT_SECRET=your_jwt_secret_here_minimum_32_characters + +# ─── Server ─────────────────────────────────────────────────────────────────── +PORT=5000 +NODE_ENV=development + +# ─── CORS ───────────────────────────────────────────────────────────────────── +# Comma-separated list of allowed frontend origins +FRONTEND_URL=http://localhost:3000 + +# ─── Email (SMTP) — optional fallbacks ─────────────────────────────────────── +# Preferred: configure SMTP via Admin → Site Settings (stored AES-256 encrypted). +# These env vars are used as fallbacks if no SMTP settings are saved in the DB. +# EMAIL_HOST=smtp.example.com +# EMAIL_PORT=587 +# EMAIL_USER=your_email@example.com +# EMAIL_PASS=your_email_password +# EMAIL_FROM=no-reply@example.com + +# ─── Payments (Yoco) ────────────────────────────────────────────────────────── +YOCO_SECRET_KEY=your_yoco_secret_key +YOCO_WEBHOOK_SECRET=your_yoco_webhook_secret + +# ─── Public URLs ────────────────────────────────────────────────────────────── +# Used to build links in emails (password reset, activation, etc.) +APP_BASE_URL=https://your-frontend-domain.com +# Used to serve ticket PDFs to WhatsApp (must be publicly reachable) +BACKEND_URL=https://your-backend-domain.com + +# ─── WhatsApp (WAWP) — optional, preferred stored via Admin → Site Settings ─── +# These are env-var fallbacks. Use the admin panel to manage them at runtime. +WAWP_ACCESS_TOKEN=your_wawp_access_token +WAWP_INSTANCE_ID=your_wawp_instance_id + +# ─── Background workers ─────────────────────────────────────────────────────── +DAILY_SUMMARY_ENABLED=true +SCHEDULED_EMAILS_ENABLED=true +SCHEDULED_EMAILS_INTERVAL_MS=30000 + +# ─── Note ───────────────────────────────────────────────────────────────────── +# The following are managed via Admin → Site Settings and stored in the database: +# - Organisation name, tagline, contact details, branding colour, logo +# - Registration notification emails +# - SMTP settings (username and password stored AES-256-GCM encrypted, key +# derived from JWT_SECRET — do not change JWT_SECRET after saving SMTP creds) +# - Legal page content (operator name, Information Officer, website URL, etc.) +# The first-time setup wizard handles initial configuration on fresh deployments. +# You no longer need ORG_NAME, ORG_TAGLINE, EMAIL_HEADER_COLOR, REGISTRATIONS_EMAIL, +# or SMTP vars in this file (they still work as fallbacks if the DB entry is absent). \ No newline at end of file diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md new file mode 100644 index 0000000..89f23af --- /dev/null +++ b/backend/API_DOCUMENTATION.md @@ -0,0 +1,525 @@ +# Hope Events — API Documentation + +Full interactive docs (with request/response examples) are available at `GET /docs?token=` on the running backend. + +All authenticated requests require: +``` +Authorization: Bearer +``` + +Tokens are issued by `POST /api/users/login`. + +--- + +## Table of Contents + +- [Users](#users) +- [Events](#events) +- [Event Options & Variants](#event-options--variants) +- [Event Attachments](#event-attachments) +- [Registrations](#registrations) +- [Payments](#payments) +- [Tickets](#tickets) +- [Broadcasts (Email)](#broadcasts-email) +- [WhatsApp Broadcasts](#whatsapp-broadcasts) +- [Scheduled Messages](#scheduled-messages) +- [Automations](#automations) +- [Reports](#reports) +- [Uploads](#uploads) +- [Sections](#sections) +- [Banner](#banner) +- [Forms](#forms) +- [Yoco Transactions](#yoco-transactions) +- [WhatsApp Admin](#whatsapp-admin) +- [Webhooks](#webhooks) +- [Settings](#settings) +- [Setup](#setup) +- [Static Files](#static-files) + +--- + +## Users + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/users` | public | Register new account | +| POST | `/api/users/login` | public | Login, returns JWT | +| POST | `/api/users/forgot` | public | Request password reset email | +| POST | `/api/users/reset` | public | Reset password with token | +| POST | `/api/users/activate` | public | Activate account from email link | +| GET | `/api/users/profile` | user+ | Own profile | +| PUT | `/api/users/profile` | user+ | Update own profile | +| POST | `/api/users/revoke-sessions` | user+ | Revoke own sessions | +| POST | `/api/users/close-account` | user+ | Deactivate own account | +| GET | `/api/users` | supervisor+ | List users — supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` | +| GET | `/api/users/:id` | admin | Get user by ID | +| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber) | +| DELETE | `/api/users/:id` | admin | Deactivate user (`isActive: false`) | +| POST | `/api/users/:id/revoke-sessions` | admin | Revoke all sessions for a user | +| POST | `/api/users/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account | + +### POST /api/users +```json +{ + "name": "Jane Smith", + "email": "jane@example.com", + "password": "securepass123", + "phoneNumber": "+27821234567" +} +``` +Returns: `{ _id, name, email, role, token }` + +### POST /api/users/login +```json +{ "email": "jane@example.com", "password": "securepass123" } +``` +Returns: `{ _id, name, email, role, token }` + +### PUT /api/users/profile +```json +{ "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" } +``` +`notificationPreference`: `"email"` | `"whatsapp"` | `"both"` + +--- + +## Events + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/events` | public | Active, public, upcoming events. Includes `isSoldOut: boolean` per event. | +| GET | `/api/events/all` | staff+ | All events including hidden/past (`?includePast=true`, `?includeInactive=true`) | +| GET | `/api/events/admin/all` | admin | Admin view of all events | +| GET | `/api/events/:id` | public | Full event detail — options, variants, earlyBirdTiers, attachments, form, availableCount per option/variant | +| GET | `/api/events/by-alias/:redirectUrl` | public | Get event by URL alias | +| POST | `/api/events` | supervisor+ | Create event | +| PUT | `/api/events/:id` | supervisor+ | Update event | +| DELETE | `/api/events/:id` | admin | Delete event | +| POST | `/api/events/:id/email-attendees` | supervisor+ | Bulk email attendees (`dryRun: true` to preview) | +| POST | `/api/events/:id/email-attendees/schedule` | supervisor+ | Schedule bulk email | +| POST | `/api/events/:id/whatsapp-attendees` | supervisor+ | Bulk WhatsApp to attendees (`dryRun: true` to preview) | +| POST | `/api/events/:id/whatsapp-attendees/schedule` | supervisor+ | Schedule bulk WhatsApp | +| GET | `/api/events/attachments/status` | admin | Attachment manifest sync status | +| POST | `/api/events/attachments/sync` | admin | Re-sync attachment manifests to DB | + +### isSoldOut logic (`GET /api/events`) +`isSoldOut` is `true` when every option on the event that has `stockLimit > 0` has sold out its full allocation (computed from non-cancelled `RegistrationOption` quantities). Events with no limited options are never marked sold out. + +### POST /api/events +```json +{ + "title": "Family Camp 2025", + "description": "Annual family camp at the farm.", + "startDate": "2025-07-10T08:00:00.000Z", + "endDate": "2025-07-14T17:00:00.000Z", + "registrationDeadline": "2025-07-01T00:00:00.000Z", + "goLiveAt": "2025-05-01T00:00:00.000Z", + "price": 450, + "picture": "/uploads/events/camp.jpg", + "redirectUrl": "camp2025", + "isHidden": false, + "requiresAuth": true, + "form": { + "isRequired": true, + "fields": [ + { "type": "text", "label": "Dietary requirements", "isRequired": false, "order": 0 } + ] + } +} +``` + +--- + +## Event Options & Variants + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/events/:id/options` | supervisor+ | Add option to event | +| PUT | `/api/events/options/:id` | supervisor+ | Update option (name, price, stockLimit, variants, earlyBirdTiers) | +| DELETE | `/api/events/options/:id` | admin | Delete option (fails if registrations exist) | +| POST | `/api/events/options/:id/variants` | supervisor+ | Add variant to option | +| PUT | `/api/events/variants/:id` | supervisor+ | Update variant | +| DELETE | `/api/events/variants/:id` | admin | Delete variant | + +### POST /api/events/:id/options +```json +{ + "name": "Adult", + "price": 450, + "isMainTicket": false, + "stockLimit": 100 +} +``` + +### PUT /api/events/options/:id — with variants and early-bird tiers +```json +{ + "name": "Adult", + "price": 450, + "stockLimit": 100, + "variants": [ + { "name": "Standard", "price": null, "stockLimit": 60, "order": 0 }, + { "name": "VIP", "price": 600, "stockLimit": 40, "order": 1 } + ], + "earlyBirdTiers": [ + { "deadline": "2025-06-01T00:00:00.000Z", "price": 350, "stockLimit": 30, "order": 0, "variantId": null } + ] +} +``` + +`stockLimit: 0` means unlimited. Early-bird tiers can be option-level (`variantId: null`) or variant-level (`variantId: ""`). + +--- + +## Event Attachments + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/events/:id/attachments` | public | List attachments for event | +| POST | `/api/events/:id/attachments` | supervisor+ | Upload attachment (`multipart/form-data`, field `file`) | +| DELETE | `/api/events/:eventId/attachments/:id` | supervisor+ | Delete attachment | + +Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, `.txt`, `.csv`, `.zip`. Max 10 MB. + +--- + +## Registrations + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/registrations` | optional | Create registration | +| GET | `/api/registrations/myregistrations` | user+ | Own registrations | +| GET | `/api/registrations/:id` | optional | Get registration by ID | +| PUT | `/api/registrations/:id/options` | user+ | Update selected options | +| DELETE | `/api/registrations/:id` | user+ | Cancel registration (blocked if positive payments exist unless admin) | +| GET | `/api/registrations/:id/forms/draft` | optional | Get form draft | +| PUT | `/api/registrations/:id/forms/draft` | optional | Save form draft | +| POST | `/api/registrations/:id/forms/responses` | optional | Submit form responses | +| PUT | `/api/registrations/:id/forms/responses` | supervisor+ | Replace form responses | +| GET | `/api/registrations` | staff+ | All registrations | +| PUT | `/api/registrations/:id` | staff+ | Update registration status | +| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event | +| POST | `/api/registrations/manual` | supervisor+ | Manual registration | + +### POST /api/registrations +```json +{ + "eventId": "ev-uuid-...", + "options": [ + { "eventOptionId": "opt-uuid-...", "quantity": 2 }, + { "eventOptionId": "opt-uuid-...", "quantity": 1, "variantId": "variant-uuid-..." } + ] +} +``` +For unauthenticated (guest) registration on events with `requiresAuth: false`: +```json +{ + "eventId": "ev-uuid-...", + "options": [...], + "guestName": "Jane Smith", + "guestEmail": "jane@example.com", + "guestPhone": "+27821234567" +} +``` + +### Cancellation rules + +| Actor | Condition | Result | +|-------|-----------|--------| +| Owner | No positive payments | 200 — status set to `cancelled` | +| Owner | Any positive payment exists | 403 — must contact organisation | +| Admin | Any state | 200 — always allowed | +| Anyone | Already cancelled | 400 | + +--- + +## Payments + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/payments` | supervisor+ | Record manual payment (cash/EFT) | +| POST | `/api/payments/yoco-checkout` | user+ | Initiate Yoco checkout; re-evaluates early-bird pricing first — returns `{ priceUpdated: true, newTotal }` if prices changed | +| GET | `/api/payments/mypayments` | user+ | Own payments | +| GET | `/api/payments/:id` | user+ | Payment by ID | +| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration | +| GET | `/api/payments` | supervisor+ | All payments | +| GET | `/api/payments/event/:eventId` | staff+ | Payments for an event | +| PUT | `/api/payments/assign-donation` | supervisor+ | Assign a donation payment to a registration | +| POST | `/api/payments/refund` | supervisor+ | Create refund record | +| GET | `/api/payments/admin/stats` | admin | Payment statistics | + +### POST /api/payments (manual) +```json +{ + "registrationId": "reg-uuid-...", + "amount": 450, + "method": "cash", + "note": "Paid at the door" +} +``` + +### POST /api/payments/yoco-checkout +```json +{ + "registrationId": "reg-uuid-...", + "successUrl": "https://events.yourchurch.org/payment/success", + "cancelUrl": "https://events.yourchurch.org/payment/cancel", + "failureUrl": "https://events.yourchurch.org/payment/failure" +} +``` +Returns: `{ redirectUrl }` or `{ priceUpdated: true, newTotal, message }` if early-bird prices changed. + +--- + +## Tickets + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/tickets/mytickets` | user+ | Own tickets (excludes cancelled registrations) | +| GET | `/api/tickets/:id` | user+ | Ticket by ID | +| POST | `/api/tickets/email` | user+ | Email own tickets as PDF | +| POST | `/api/tickets/send-to` | staff+ | Send tickets to a specific user | +| GET | `/api/tickets/scan-preview/:qrCode` | staff+ | Preview scan (no mark) | +| GET | `/api/tickets/qr/:qrCode` | staff+ | Ticket by QR code | +| POST | `/api/tickets/scan/:qrCode` | staff+ | Scan/redeem ticket | +| GET | `/api/tickets/event/:eventId` | staff+ | All tickets for an event | +| GET | `/api/tickets/scans/recent` | staff+ | Recent scan activity | +| GET | `/api/tickets/scans/stats` | staff+ | Scan statistics | +| POST | `/api/tickets/generate` | supervisor+ | Generate tickets for a registration | +| GET | `/api/tickets` | supervisor+ | All tickets | +| PUT | `/api/tickets/email-sent` | admin | Bulk mark tickets as emailed | + +--- + +## Broadcasts (Email) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/broadcasts/preview` | supervisor+ | Dry-run — returns recipient count and sample | +| POST | `/api/broadcasts/send` | supervisor+ | Send email broadcast immediately | +| POST | `/api/broadcasts/schedule` | supervisor+ | Schedule email broadcast | + +### POST /api/broadcasts/send +```json +{ + "subject": "Camp 2025 — Final reminder", + "body": "Hi {{name}}, just a reminder that camp starts tomorrow at 08:00.", + "userIds": ["user-uuid-1", "user-uuid-2"], + "extraEmails": ["extra@example.com"] +} +``` + +Dynamic placeholders: `{{name}}`, `{{event.title}}`, `{{event.start}}`, `{{event.link}}`, `{{balance}}`, `{{promo.title}}`, `{{promo.link}}` + +--- + +## WhatsApp Broadcasts + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/whatsapp-broadcasts/preview` | supervisor+ | Dry-run | +| POST | `/api/whatsapp-broadcasts/send` | supervisor+ | Send WhatsApp broadcast immediately | +| POST | `/api/whatsapp-broadcasts/schedule` | supervisor+ | Schedule WhatsApp broadcast | + +--- + +## Scheduled Messages + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/scheduled-emails` | supervisor+ | List all queued/scheduled jobs | +| PATCH | `/api/scheduled-emails/:id` | supervisor+ | Edit scheduled job (reschedule, update content) | +| DELETE | `/api/scheduled-emails/:id` | supervisor+ | Cancel a queued job | + +--- + +## Automations + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/automations/schedule` | supervisor+ | Schedule event lifecycle automations (pre-event reminder, thank-you, promo, etc.) | + +--- + +## Reports + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/reports/pdf` | user+ | Generate and download PDF attendance/payment report | +| POST | `/api/reports/email` | user+ | Email PDF report to current user | + +--- + +## Uploads + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/uploads/event-image` | supervisor+ | Upload event cover image (`multipart/form-data`, field `image`). Returns `{ url }`. Max 5 MB. Formats: jpg, jpeg, png, webp, gif. | +| POST | `/api/uploads/logo` | admin (or unauthenticated during setup) | Upload site logo (`multipart/form-data`, field `image`). Returns `{ url }`. Max 2 MB. Formats: jpg, jpeg, png, webp, svg. | + +--- + +## Sections + +Groups of event options shown as labelled sections on the registration page (e.g. "Tickets", "Add-ons", "Accommodation"). + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/sections` | staff+ | List sections | +| POST | `/api/sections` | supervisor+ | Create section | +| PUT | `/api/sections/:id` | supervisor+ | Update section | +| DELETE | `/api/sections/:id` | admin | Delete section | + +### POST /api/sections +```json +{ + "eventId": "ev-uuid-...", + "name": "Accommodation", + "allowedOptionIds": ["opt-uuid-1", "opt-uuid-2"] +} +``` + +--- + +## Banner + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/banner` | public | Get current site-wide banner (text, colour, active flag) | +| POST | `/api/banner` | supervisor+ | Set site banner | + +### POST /api/banner +```json +{ + "text": "Camp registrations close Friday!", + "color": "#f59e0b", + "isActive": true +} +``` + +--- + +## Forms + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/forms/responses` | staff+ | List all submitted form responses across all events; supports filtering | + +--- + +## Yoco Transactions + +Raw webhook events from Yoco. Used for reconciling payments that weren't matched automatically. + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/yoco-transactions` | supervisor+ | All Yoco webhook transactions | +| GET | `/api/yoco-transactions/unreconciled` | supervisor+ | Transactions not yet matched to a payment | +| POST | `/api/yoco-transactions/:id/reconcile` | supervisor+ | Manually link transaction to a registration | +| POST | `/api/yoco-transactions/:id/ignore` | supervisor+ | Mark transaction as intentionally ignored | + +--- + +## WhatsApp Admin + +Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (preferred) or `.env` (fallback). + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials | +| POST | `/api/whatsapp/config` | admin | Save WAWP credentials to DB | +| GET | `/api/whatsapp/status` | admin | WAWP session status | +| GET | `/api/whatsapp/qr` | admin | QR code for WhatsApp device linking | +| POST | `/api/whatsapp/create-instance` | admin | Create a new WAWP instance | +| POST | `/api/whatsapp/delete-instance` | admin | Delete WAWP instance | +| POST | `/api/whatsapp/request-code` | admin | Request pairing code (alternative to QR) | +| POST | `/api/whatsapp/logout` | admin | Logout current WAWP session | +| POST | `/api/whatsapp/start` | admin | Start WAWP instance | +| POST | `/api/whatsapp/restart` | admin | Restart WAWP instance | + +--- + +## Webhooks + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies `X-Yoco-Signature`, creates `Payment` + `YocoTransaction`, updates registration status, generates tickets | +| POST | `/api/webhooks/whatsapp` | — | WAWP inbound event webhook | + +--- + +## Settings + +Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted. + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/settings` | public | Public settings (org name, tagline, colour, logo, legal keys) | +| GET | `/api/settings/all` | admin | All settings — `smtp_pass` masked as `••••••••`, `smtp_user` decrypted | +| GET | `/api/settings/needs-setup` | public | `{ needsSetup: bool }` — true until setup wizard completes | +| PUT | `/api/settings` | admin | Upsert settings; `smtp_user`/`smtp_pass` encrypted before storage; sending `••••••••` for `smtp_pass` keeps existing value | +| POST | `/api/settings/test-smtp` | admin | Test SMTP with provided credentials; sends real test email to admin on success. The admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. | + +### POST /api/settings/test-smtp +```json +{ + "host": "smtp.office365.com", + "port": 587, + "secure": false, + "user": "noreply@yourchurch.org", + "pass": "app-password-here", + "from": "noreply@yourchurch.org" +} +``` + +**Success (200):** +```json +{ "message": "SMTP connection verified — a test email has been sent to admin@yourchurch.org" } +``` + +**Failure (400):** +```json +{ + "message": "Authentication failed — check your SMTP username and password.", + "raw": "530 5.7.57 Client not authenticated to send mail. [JN3P275CA0121...]" +} +``` + +The `message` field is always a human-readable sentence. The `raw` field is included on errors only and contains the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message. + +--- + +## Setup + +One-time first-run wizard. Both endpoints are blocked once any user exists and setup is complete. + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/setup/register` | public (one-time) | Create the first admin account; returns a short-lived setup JWT | +| POST | `/api/setup` | setup token or admin | Save initial site settings (org name, branding, SMTP) | + +--- + +## Static Files + +| Path | Description | +|------|-------------| +| `/uploads/events/*` | Event cover images | +| `/uploads/branding/*` | Site logo | +| `/uploads/event-files/*` | Event attachments (PDFs, docs, etc.) | +| `/uploads/tickets-temp/*` | Temporarily hosted ticket PDFs for WhatsApp delivery (auto-deleted after 5 min) | + +--- + +## Auth error responses + +| Status | Body | Meaning | +|--------|------|---------| +| 401 | `{ message: "Not authorised, no token" }` | Missing or malformed `Authorization` header | +| 401 | `{ message: "Not authorised, token failed" }` | Invalid or expired JWT | +| 401 | `{ message: "Session has been revoked" }` | Token version mismatch (sessions revoked) | +| 403 | `{ message: "Not authorised as an admin" }` | Insufficient role | + +--- + +*For full interactive docs with expandable examples visit `GET /docs?token=` on the running backend.* \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..ab865aa --- /dev/null +++ b/backend/README.md @@ -0,0 +1,528 @@ +# Hope Events — Backend API + +Node.js + Express REST API for the Hope Events platform used by Hope Family Church (`events.hopehenley.co.za`). + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Tech Stack](#tech-stack) +3. [Prerequisites](#prerequisites) +4. [Installation](#installation) +5. [Environment Variables](#environment-variables) +6. [Database](#database) +7. [Running the Server](#running-the-server) +8. [Authentication & Roles](#authentication--roles) +9. [API Reference](#api-reference) +10. [Background Workers](#background-workers) +11. [Notification System](#notification-system) +12. [WhatsApp Integration](#whatsapp-integration) +13. [File Uploads](#file-uploads) +14. [Payments (Yoco)](#payments-yoco) +15. [Deployment](#deployment) + +--- + +## Overview + +Full-stack event management backend handling: +- User registration, authentication (JWT), and role-based access control +- Event creation, management, and public listings +- Attendee registration with optional payment tiers and early-bird pricing +- Yoco checkout integration with webhook reconciliation +- Ticket generation (PDF + QR code) and email/WhatsApp delivery +- Scheduled bulk messaging (email + WhatsApp) for attendees and broadcast lists +- Admin WhatsApp instance management via WAWP + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Runtime | Node.js 18+ | +| Framework | Express 4 | +| Database | PostgreSQL (via Prisma ORM 5) | +| Auth | JWT (`jsonwebtoken`), `bcryptjs` | +| Email | Nodemailer (any SMTP — configured via Admin → Site Settings, AES-256 encrypted in DB) | +| WhatsApp | WAWP REST API (`api.wawp.net/v2`) | +| PDF/QR | PDFKit, QRCode | +| File Uploads | Multer | +| Payments | Yoco | + +--- + +## Prerequisites + +- Node.js 18+ +- PostgreSQL 14+ (hosted at `database.crosscode.co.za` for production) +- npm + +--- + +## Installation + +```bash +cd backend +npm install # installs deps and runs `prisma generate` +``` + +--- + +## Environment Variables + +Create `backend/.env` from `.env.example`. The only variables you must set are: + +| Variable | Required | Description | +|----------|----------|-------------| +| `DATABASE_URL` | Yes | PostgreSQL connection string | +| `JWT_SECRET` | Yes | Secret key for JWTs (32+ random characters). Also used to derive the AES-256 encryption key for sensitive settings (SMTP credentials) stored in the DB — must be set before saving SMTP settings. | +| `FRONTEND_URL` | Yes | Comma-separated allowed CORS origins | +| `YOCO_SECRET_KEY` | Yes | Yoco secret API key | +| `YOCO_WEBHOOK_SECRET` | Yes | Yoco webhook HMAC secret | +| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) | +| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp | +| `PORT` | — | Port to listen on (default `3000`) | +| `NODE_ENV` | — | `production` or `development` | +| `WAWP_ACCESS_TOKEN` | — | WAWP fallback token (preferred: set via Admin → Site Settings) | +| `WAWP_INSTANCE_ID` | — | WAWP fallback instance ID (preferred: set via Admin → Site Settings) | +| `DAILY_SUMMARY_ENABLED` | — | `false` to disable daily summaries (default `true`) | +| `SCHEDULED_EMAILS_ENABLED` | — | `false` to disable scheduled-send worker (default `true`) | +| `SCHEDULED_EMAILS_INTERVAL_MS` | — | Polling interval in ms (default `30000`) | +| `SMTP_HOST` / `EMAIL_HOST` | — | Fallback SMTP host (preferred: set via Admin → Site Settings) | +| `SMTP_PORT` / `EMAIL_PORT` | — | Fallback SMTP port | +| `SMTP_USER` / `EMAIL_USER` | — | Fallback SMTP username | +| `SMTP_PASS` / `EMAIL_PASS` | — | Fallback SMTP password | +| `MAIL_FROM` / `EMAIL_FROM` | — | Fallback from address | + +> **Organisation name, branding colour, logo, registration notification email, SMTP settings, and legal page content** are now managed through the admin panel at **Admin → Site Settings** and stored in the database. You no longer need `ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, or SMTP vars in `.env` (they still work as fallbacks). On a fresh deployment these are set via the first-time setup wizard. +> +> **SMTP credentials** (`smtp_user`, `smtp_pass`) are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving SMTP credentials — the stored values will become unreadable. + +--- + +## Database + +### Schema summary + +| Model | Purpose | +|-------|---------| +| `User` | Accounts with roles (`admin`, `supervisor`, `staff`, `user`) | +| `Event` | Events with dates, pricing, options, attachments | +| `EventOption` | Ticket types / items per event; `stockLimit` (0 = unlimited), `isMainTicket` flag | +| `EarlyBirdTier` | Time-limited discounted prices; linked to an `EventOption` (option-level) or an `OptionVariant` (variant-level via nullable `variantId`); `stockLimit` (0 = unlimited) | +| `OptionVariant` | Sub-items per option (size, colour, ticket type); optional `price` override; own `stockLimit` | +| `Registration` | A user's registration for an event; `status`: pending/confirmed/partial_paid/paid/cancelled | +| `RegistrationOption` | Which options (and quantities) a registration includes; `priceSnapshot`, `appliedTierId`, `variantId` | +| `Payment` | Payment records (Yoco, manual, donation, refund) | +| `Ticket` | QR-code tickets linked to registration options | +| `TicketUsage` | Scan log for each ticket redemption | +| `PasswordReset` | One-time tokens for password reset flow | +| `EventAttachment` | Files attached to events (PDFs, images) | +| `EventForm` / `EventFormField` | Per-event custom forms | +| `FormResponse` / `FormAnswer` | Submitted form data from attendees | +| `FormDraft` | Auto-saved in-progress form data | +| `YocoTransaction` | Raw webhook events from Yoco for reconciliation | +| `Section` / `SectionOption` | Grouping of event options into sections | +| `AppSetting` | Key-value store for runtime config — WAWP credentials, org name, branding, notification emails, SMTP settings (credentials stored AES-256-GCM encrypted), legal page content | + +### Migrations + +```bash +# Apply pending migrations (production) +npm run prisma:deploy + +# Check migration status +npm run prisma:status + +# Regenerate Prisma client after schema changes +npm run prisma:generate +``` + +--- + +## Running the Server + +```bash +# Development (auto-reload) +npm run dev + +# Production +npm start +``` + +Visit `/` for the status page. Visit `/docs?token=` for full API documentation (admin only). + +--- + +## Authentication & Roles + +All protected routes require a `Bearer` token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +Tokens are issued on `POST /api/users/login`. Token version is stored per-user; revoking sessions increments the version and invalidates all existing tokens for that user. + +### Role Hierarchy + +| Role | Description | +|------|-------------| +| `admin` | Full access — user management, delete operations, WhatsApp admin, payment stats | +| `supervisor` | Event/registration/payment management, bulk messaging, reports | +| `staff` | Read access to registrations, payments, tickets; ticket scanning | +| `user` | Own profile, own registrations, own tickets | + +### Middleware + +| Middleware | Applied when | +|-----------|-------------| +| `protect` | Any authenticated route | +| `admin` | Admin-only actions | +| `supervisor` | Supervisor + admin | +| `staff` | Staff + supervisor + admin | +| `optionalAuth` | Routes that work for guests but enrich responses if authenticated | +| `loginLimiter` | Rate-limits login to 15 req/min per IP | + +--- + +## API Reference + +Full interactive API docs available at `GET /docs` (requires admin JWT — pass via `?token=` query param or `Authorization: Bearer` header). + +### Quick reference by domain + +#### Users — `/api/users` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/` | public | Register a new account | +| POST | `/login` | public | Login, returns JWT | +| POST | `/forgot` | public | Request password reset email | +| POST | `/reset` | public | Reset password with token | +| POST | `/activate` | public | Activate account from email link | +| GET | `/profile` | user+ | Get own profile | +| PUT | `/profile` | user+ | Update own profile | +| POST | `/revoke-sessions` | user+ | Revoke own sessions | +| POST | `/close-account` | user+ | Deactivate own account | +| GET | `/` | supervisor+ | List all users (paginated); supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` | +| GET | `/:id` | admin | Get user by ID | +| PUT | `/:id` | admin | Update user | +| DELETE | `/:id` | admin | Deactivate user (sets `isActive: false`) | +| POST | `/:id/revoke-sessions` | admin | Revoke all sessions for a user | +| POST | `/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account, revokes all sessions | + +#### Events — `/api/events` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/` | public | List public/active upcoming events; response includes `isSoldOut: boolean` per event | +| GET | `/all` | staff+ | All events including hidden/past | +| GET | `/admin/all` | admin | All events (admin view) | +| GET | `/attachments/status` | admin | Attachment manifest sync status | +| POST | `/attachments/sync` | admin | Re-sync attachment manifests to DB | +| GET | `/:id` | public | Get event by ID | +| GET | `/by-alias/:redirectUrl` | public | Get event by redirect alias | +| POST | `/` | supervisor+ | Create event | +| PUT | `/:id` | supervisor+ | Update event | +| DELETE | `/:id` | admin | Delete event | +| GET | `/:id/attachments` | public | List event file attachments | +| POST | `/:id/attachments` | supervisor+ | Upload attachment | +| DELETE | `/:eventId/attachments/:id` | supervisor+ | Delete attachment | +| POST | `/:id/email-attendees` | supervisor+ | Bulk email attendees (supports `dryRun`) | +| POST | `/:id/email-attendees/schedule` | supervisor+ | Schedule bulk email | +| POST | `/:id/whatsapp-attendees` | supervisor+ | Bulk WhatsApp to attendees (supports `dryRun`) | +| POST | `/:id/whatsapp-attendees/schedule` | supervisor+ | Schedule bulk WhatsApp | +| POST | `/:id/options` | supervisor+ | Add event option | +| PUT | `/options/:id` | supervisor+ | Update event option | +| DELETE | `/options/:id` | admin | Delete event option | + +#### Registrations — `/api/registrations` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/` | optional | Create registration | +| GET | `/myregistrations` | user+ | Own registrations | +| PUT | `/:id/options` | user+ | Update registration options | +| DELETE | `/:id` | user+ | Cancel registration | +| GET | `/:id` | optional | Get registration by ID | +| GET | `/:id/forms/draft` | optional | Get form draft | +| PUT | `/:id/forms/draft` | optional | Save form draft | +| POST | `/:id/forms/responses` | optional | Submit form responses | +| PUT | `/:id/forms/responses` | supervisor+ | Replace form responses | +| GET | `/` | staff+ | List all registrations | +| PUT | `/:id` | staff+ | Update registration status | +| GET | `/event/:eventId` | staff+ | Registrations for an event | +| POST | `/manual` | supervisor+ | Manual registration | + +#### Payments — `/api/payments` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/` | supervisor+ | Record manual payment | +| POST | `/yoco-checkout` | user+ | Initiate Yoco checkout | +| GET | `/mypayments` | user+ | Own payments | +| GET | `/:id` | user+ | Payment by ID | +| GET | `/registration/:registrationId` | user+ | Payments for a registration | +| GET | `/` | supervisor+ | All payments | +| GET | `/event/:eventId` | staff+ | Payments for an event | +| PUT | `/assign-donation` | supervisor+ | Assign donation to registration | +| POST | `/refund` | supervisor+ | Create refund | +| GET | `/admin/stats` | admin | Payment statistics | + +#### Tickets — `/api/tickets` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/mytickets` | user+ | Own tickets | +| GET | `/:id` | user+ | Ticket by ID | +| POST | `/email` | user+ | Email own tickets | +| POST | `/send-to` | staff+ | Send tickets to a specific user | +| GET | `/scan-preview/:qrCode` | staff+ | Preview scan (no mark) | +| GET | `/qr/:qrCode` | staff+ | Ticket by QR code | +| POST | `/scan/:qrCode` | staff+ | Scan/redeem ticket | +| GET | `/event/:eventId` | staff+ | All tickets for event | +| GET | `/scans/recent` | staff+ | Recent scan activity | +| GET | `/scans/stats` | staff+ | Scan statistics | +| POST | `/generate` | supervisor+ | Generate tickets for registration | +| GET | `/` | supervisor+ | All tickets | +| PUT | `/email-sent` | admin | Bulk mark tickets as emailed | + +#### Webhooks — `/api/webhooks` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/yoco` | HMAC | Yoco payment webhook | +| POST | `/whatsapp` | — | WAWP inbound webhook | + +#### Broadcasts — `/api/broadcasts` (email) +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/preview` | supervisor+ | Dry-run, returns recipient count/sample | +| POST | `/send` | supervisor+ | Send email broadcast | +| POST | `/schedule` | supervisor+ | Schedule email broadcast | + +#### WhatsApp Broadcasts — `/api/whatsapp-broadcasts` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/preview` | supervisor+ | Dry-run | +| POST | `/send` | supervisor+ | Send WA broadcast | +| POST | `/schedule` | supervisor+ | Schedule WA broadcast | + +#### Scheduled Messages — `/api/scheduled-emails` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/` | supervisor+ | List all scheduled jobs | +| PATCH | `/:id` | supervisor+ | Edit a queued job | +| DELETE | `/:id` | supervisor+ | Cancel a queued job | + +#### Automations — `/api/automations` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/schedule` | supervisor+ | Schedule event lifecycle automations | + +#### Reports — `/api/reports` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/pdf` | user+ | Generate and download PDF report | +| POST | `/email` | user+ | Email PDF report to current user | + +#### Settings — `/api/settings` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/` | public | Public settings (org name, colour, logo, legal keys, etc.) | +| GET | `/all` | admin | All settings — `smtp_pass` returned masked (`••••••••`), `smtp_user` decrypted | +| GET | `/needs-setup` | public | `{ needsSetup: bool }` — true until setup wizard completes | +| PUT | `/` | admin | Upsert settings; `smtp_user` and `smtp_pass` are AES-256-GCM encrypted before storage; sending `••••••••` for `smtp_pass` is a no-op (keeps existing value) | +| POST | `/test-smtp` | admin | Test SMTP connection with provided credentials; success returns `{ message }`, failure returns `{ message, raw }` where `message` is human-readable and `raw` is the original SMTP error. The short-lived admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. | + +#### Setup — `/api/setup` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/register` | public (one-time) | Step 2 of setup wizard — creates the first admin account and returns a short-lived JWT setup token; blocked once any user exists | +| POST | `/` | setup token or admin | Final step of setup wizard — saves initial site settings (SMTP, org name, branding); requires setup token from `/register` | + +#### Uploads — `/api/uploads` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/event-image` | supervisor+ | Upload event cover image (`image` field) | +| POST | `/logo` | admin (or no-auth during setup) | Upload site logo (`image` field) | + +#### Sections — `/api/sections` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/` | staff+ | List sections | +| POST | `/` | supervisor+ | Create section | +| PUT | `/:id` | supervisor+ | Update section | +| DELETE | `/:id` | admin | Delete section | + +#### Banner — `/api/banner` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/` | public | Get current site banner | +| POST | `/` | supervisor+ | Set site banner | + +#### Forms — `/api/forms` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/responses` | staff+ | List all form responses | + +#### Yoco Transactions — `/api/yoco-transactions` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/` | supervisor+ | All Yoco webhook transactions | +| GET | `/unreconciled` | supervisor+ | Unmatched transactions | +| POST | `/:id/reconcile` | supervisor+ | Manually reconcile transaction | +| POST | `/:id/ignore` | supervisor+ | Mark transaction as ignored | + +#### WhatsApp Admin — `/api/whatsapp` +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/webhook` | public | WAWP inbound event | +| GET | `/config` | admin | Get WAWP credentials | +| POST | `/config` | admin | Save WAWP credentials to DB | +| POST | `/create-instance` | admin | Create WAWP instance | +| POST | `/delete-instance` | admin | Delete WAWP instance | +| GET | `/status` | admin | WAWP session status | +| GET | `/qr` | admin | QR code for WhatsApp linking | +| POST | `/request-code` | admin | Request pairing code | +| POST | `/logout` | admin | Logout WAWP session | +| POST | `/start` | admin | Start WAWP instance | +| POST | `/restart` | admin | Restart WAWP instance | + +#### Static Files +| Path | Description | +|------|-------------| +| `/uploads/*` | Served uploaded files (images, PDFs) | + +--- + +## Background Workers + +All workers start automatically with `npm start`. + +### Scheduled Emails Worker +Polls every 30 s (configurable via `SCHEDULED_EMAILS_INTERVAL_MS`) for due jobs. Handles attendee emails, attendee WhatsApp, email broadcasts, and WhatsApp broadcasts. Disable with `SCHEDULED_EMAILS_ENABLED=false`. + +### Daily Event Summaries +Runs at 07:00 local time. Sends summary emails to supervisors about that day's events. Disable with `DAILY_SUMMARY_ENABLED=false`. + +### Temp File Cleanup +Runs at 03:00 local time. Removes files older than 7 days from `public/uploads/tickets-temp/`. + +### Attachment Manifest Sync +Runs once on startup. Imports on-disk attachment manifests not yet in the database. + +--- + +## Notification System + +Outbound notifications are routed through `src/utils/notify.js`: + +- `shouldEmail(user)` — true if preference is `email` or `both` +- `canWhatsApp(user)` — true if preference is `whatsapp` or `both` and user has a phone number +- `waText(user, message)` — sends a WhatsApp text +- `waPdf(user, pdfPath, caption)` — copies PDF to temp, sends URL to WAWP, cleans up after 5 min + +**Automated notifications**: login, account deletion, ticket delivery, payment confirmation, password reset, daily summaries. + +**Dynamic placeholders** in bulk messages: `{{name}}`, `{{event.title}}`, `{{event.start}}`, `{{event.link}}`, `{{balance}}`, `{{promo.title}}`, `{{promo.link}}`. + +--- + +## WhatsApp Integration + +WAWP API (`api.wawp.net/v2`). All calls go through `src/utils/whatsapp.js`. + +Credentials are stored in `AppSetting` (`WAWP_ACCESS_TOKEN`, `WAWP_INSTANCE_ID`), with `.env` as fallback. Cached in memory for 30 s. + +"Session not found" recovery: stale session is deleted and a new one is created automatically, saving the new `instance_id` to `AppSetting`. + +Admin UI: `/dashboard/admin/whatsapp`. + +--- + +## File Uploads + +- Event images: `POST /api/uploads/event-image` (field `image`), saved to `public/uploads/` +- Event attachments: `POST /api/events/:id/attachments` (field `file`) +- Ticket PDFs: generated on demand, temporarily in `public/uploads/tickets-temp/`, auto-deleted after 5 min + +Files are served from `/uploads/*`. + +--- + +## Pricing & Cancellation Rules + +### Cancellation + +`DELETE /api/registrations/:id` + +| Actor | Condition | Result | +|-------|-----------|--------| +| Owner (user) | No positive payments recorded | 200 — status set to `cancelled` | +| Owner (user) | Any positive payment exists | 403 — must contact organisation | +| Admin | Any state | 200 — always allowed | +| Anyone | Already cancelled | 400 | + +Cancelled registrations are excluded from all stock-limit counts (option, variant, and tier stock). + +### Early-bird pricing — two-phase evaluation + +Prices are evaluated in two phases: + +**Phase 1 — Registration time** (`resolveOptionPrice` in `src/utils/pricing.js`) +- Selects the cheapest applicable tier, checking **both** the deadline **and** the stock limit. +- Result stored as `priceSnapshot` and `appliedTierId` on each `RegistrationOption`. +- Tier sort order: cheapest first; earliest deadline breaks ties. + +**Phase 2 — Payment time** (`refreshPricingForRegistration` in `src/utils/pricing.js`) +- Called automatically before any Yoco checkout is created or any manual payment is recorded. +- Re-runs `resolveOptionPrice` for every `RegistrationOption` that has an `appliedTierId`. +- If a tier has since expired **or its stock has been exhausted**, `priceSnapshot` and `appliedTierId` are updated in the DB to reflect the next applicable tier or the base price. +- If prices changed, `POST /api/payments/yoco-checkout` returns HTTP 200 with `{ priceUpdated: true, newTotal, message }` instead of proceeding to Yoco. The frontend must present the updated total before the user can retry. +- For manual payments (`POST /api/payments`) prices are refreshed silently — no blocking response. + +**`computeRegistrationTotalDue`** (also in `src/utils/pricing.js`) +- Uses `priceSnapshot` as the authoritative price when it is set on a `RegistrationOption`. +- Falls back to `getEffectiveUnitPrice` (deadline-only re-evaluation) for legacy rows without a snapshot. + +### Variant + tier interaction + +Early-bird tiers can be defined at either the **option level** (no `variantId`) or the **variant level** (`variantId` set). Both are stored as `EarlyBirdTier` records on the `EventOption`. + +| Variant state | Effective price | +|---------------|-----------------| +| No variant selected | Option-level tiers are evaluated via `resolveOptionPrice` | +| Variant selected, variant has its own tier(s) | Variant-level tiers are evaluated first via `resolveVariantTierPrice` | +| Variant selected, no variant-level tier | Variant's own `price` is used (or option price if `variant.price` is null) | + +The resolution priority at registration/payment time is: **variant tier → variant price → option tier → option base price**. + +--- + +## Payments (Yoco) + +1. Frontend calls `POST /api/payments/yoco-checkout` +2. Backend calls `refreshPricingForRegistration` — if any early-bird price changed, returns `{ priceUpdated: true, newTotal }` (HTTP 200) and does **not** create a checkout. Frontend shows warning; user retries. +3. On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns `{ redirectUrl }`. +4. User pays on Yoco's hosted page. +5. Yoco sends webhook to `POST /api/webhooks/yoco`. +6. Backend verifies HMAC, creates `Payment` + `YocoTransaction`, updates registration status, generates tickets. +7. Unmatched transactions visible at `/api/yoco-transactions/unreconciled`. + +--- + +## Deployment + +### PM2 (recommended) +```bash +npm install -g pm2 +pm2 start src/index.js --name hope-events-api +pm2 save && pm2 startup +``` + +### Checklist +- [ ] `DATABASE_URL` points to production DB +- [ ] `JWT_SECRET` is long (32+ chars) and secret +- [ ] `FRONTEND_URL` lists only production origins +- [ ] `NODE_ENV=production` +- [ ] Yoco webhook registered and HTTPS +- [ ] WAWP credentials saved via admin panel +- [ ] SMTP credentials correct \ No newline at end of file diff --git a/backend/data/banner.json b/backend/data/banner.json new file mode 100644 index 0000000..66f1acb --- /dev/null +++ b/backend/data/banner.json @@ -0,0 +1,6 @@ +{ + "message": "", + "type": "info", + "liveFrom": null, + "liveTill": null +} \ No newline at end of file diff --git a/backend/data/scheduled-emails.json b/backend/data/scheduled-emails.json new file mode 100644 index 0000000..5d591f6 --- /dev/null +++ b/backend/data/scheduled-emails.json @@ -0,0 +1,3 @@ +{ + "jobs": [] +} \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..4a17698 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2260 @@ +{ + "name": "event-management-backend", + "version": "1.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "event-management-backend", + "version": "1.2", + "hasInstallScript": true, + "dependencies": { + "@prisma/client": "^5.4.2", + "axios": "^1.11.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", + "node-fetch": "^2.7.0", + "nodemailer": "^7.0.5", + "pdfkit": "^0.17.1", + "qrcode": "^1.5.4", + "raw-body": "^3.0.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "prisma": "^5.4.2" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "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/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.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==", + "dev": true, + "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/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "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/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "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/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "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/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "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/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/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "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/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/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/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.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==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/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==", + "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-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/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/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/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/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/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/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/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/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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-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-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/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "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/jsonwebtoken/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/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/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/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/nodemailer": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "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/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "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/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-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "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==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pdfkit": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.1.tgz", + "integrity": "sha512-Kkf1I9no14O/uo593DYph5u3QwiMfby7JsBSErN1WqeyTgCBNJE3K4pXBn3TgkdKUIVu+buSl4bYUNC+8Up4xg==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^2.0.4", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, + "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/png-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", + "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "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/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "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==", + "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==", + "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==", + "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==", + "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/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/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "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-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/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/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "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/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "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": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "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-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "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/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/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/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7e494e6 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,38 @@ +{ + "name": "event-management-backend", + "version": "1.0", + "description": "Event Management System Backend", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "test": "echo \"Error: no test specified\" && exit 1", + "postinstall": "prisma generate", + "prisma:generate": "prisma generate", + "prisma:deploy": "prisma migrate deploy && prisma generate", + "prisma:status": "prisma migrate status", + "prisma:reset": "prisma migrate reset --force", + "migrate:env-to-db": "node scripts/migrate-env-to-db.js", + "migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run" + }, + "dependencies": { + "@prisma/client": "^5.4.2", + "axios": "^1.11.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", + "node-fetch": "^2.7.0", + "nodemailer": "^7.0.5", + "pdfkit": "^0.17.1", + "qrcode": "^1.5.4", + "raw-body": "^3.0.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "prisma": "^5.4.2" + } +} diff --git a/backend/prisma/migrations/20260221095332_init/migration.sql b/backend/prisma/migrations/20260221095332_init/migration.sql new file mode 100644 index 0000000..96556e1 --- /dev/null +++ b/backend/prisma/migrations/20260221095332_init/migration.sql @@ -0,0 +1,342 @@ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('admin', 'supervisor', 'staff', 'user'); + +-- CreateEnum +CREATE TYPE "RegistrationStatus" AS ENUM ('pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'); + +-- CreateEnum +CREATE TYPE "FormFieldType" AS ENUM ('yes_no', 'text', 'date', 'numeric', 'statement', 'paragraph'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "phoneNumber" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'user', + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Event" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "registrationDeadline" TIMESTAMP(3), + "goLiveAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "price" DOUBLE PRECISION NOT NULL, + "picture" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdById" TEXT, + "redirectUrl" TEXT, + + CONSTRAINT "Event_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventOption" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "price" DOUBLE PRECISION NOT NULL, + "isMainTicket" BOOLEAN NOT NULL, + + CONSTRAINT "EventOption_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EarlyBirdTier" ( + "id" TEXT NOT NULL, + "eventOptionId" TEXT NOT NULL, + "deadline" TIMESTAMP(3) NOT NULL, + "price" DOUBLE PRECISION NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "EarlyBirdTier_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Registration" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "status" "RegistrationStatus" NOT NULL DEFAULT 'pending', + "checkoutId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Registration_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RegistrationOption" ( + "id" TEXT NOT NULL, + "registrationId" TEXT NOT NULL, + "eventOptionId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "RegistrationOption_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Payment" ( + "id" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "method" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "registrationId" TEXT, + "eventId" TEXT, + "isDonation" BOOLEAN NOT NULL DEFAULT false, + "externalId" TEXT, + "status" TEXT, + "originalPaymentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Ticket" ( + "id" TEXT NOT NULL, + "qrCode" TEXT NOT NULL, + "registrationOptionId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "isUsed" BOOLEAN NOT NULL DEFAULT false, + "emailSent" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TicketUsage" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "scannedById" TEXT NOT NULL, + "scannedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TicketUsage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PasswordReset" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PasswordReset_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventAttachment" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "originalName" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EventAttachment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventForm" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "isRequired" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EventForm_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventFormField" ( + "id" TEXT NOT NULL, + "formId" TEXT NOT NULL, + "type" "FormFieldType" NOT NULL, + "label" TEXT NOT NULL, + "isRequired" BOOLEAN NOT NULL DEFAULT false, + "order" INTEGER NOT NULL DEFAULT 0, + "helpText" TEXT, + "options" JSONB, + + CONSTRAINT "EventFormField_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FormResponse" ( + "id" TEXT NOT NULL, + "registrationId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FormResponse_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FormAnswer" ( + "id" TEXT NOT NULL, + "responseId" TEXT NOT NULL, + "fieldId" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "FormAnswer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FormDraft" ( + "id" TEXT NOT NULL, + "registrationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "data" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FormDraft_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "YocoTransaction" ( + "id" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "amount" INTEGER, + "currency" TEXT, + "createdDate" TIMESTAMP(3), + "checkoutId" TEXT, + "methodType" TEXT, + "reconciled" BOOLEAN NOT NULL DEFAULT false, + "ignored" BOOLEAN NOT NULL DEFAULT false, + "paymentId" TEXT, + "raw" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "YocoTransaction_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Event_redirectUrl_key" ON "Event"("redirectUrl"); + +-- CreateIndex +CREATE INDEX "Event_createdById_idx" ON "Event"("createdById"); + +-- CreateIndex +CREATE INDEX "EarlyBirdTier_eventOptionId_deadline_idx" ON "EarlyBirdTier"("eventOptionId", "deadline"); + +-- CreateIndex +CREATE UNIQUE INDEX "Registration_checkoutId_key" ON "Registration"("checkoutId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_externalId_key" ON "Payment"("externalId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Ticket_qrCode_key" ON "Ticket"("qrCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordReset_token_key" ON "PasswordReset"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "EventForm_eventId_key" ON "EventForm"("eventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FormDraft_registrationId_userId_key" ON "FormDraft"("registrationId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "YocoTransaction_externalId_key" ON "YocoTransaction"("externalId"); + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventOption" ADD CONSTRAINT "EventOption_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EarlyBirdTier" ADD CONSTRAINT "EarlyBirdTier_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Registration" ADD CONSTRAINT "Registration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Registration" ADD CONSTRAINT "Registration_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_originalPaymentId_fkey" FOREIGN KEY ("originalPaymentId") REFERENCES "Payment"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_scannedById_fkey" FOREIGN KEY ("scannedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PasswordReset" ADD CONSTRAINT "PasswordReset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventAttachment" ADD CONSTRAINT "EventAttachment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventForm" ADD CONSTRAINT "EventForm_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventFormField" ADD CONSTRAINT "EventFormField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "EventForm"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FormAnswer" ADD CONSTRAINT "FormAnswer_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FormAnswer" ADD CONSTRAINT "FormAnswer_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "EventFormField"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FormDraft" ADD CONSTRAINT "FormDraft_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FormDraft" ADD CONSTRAINT "FormDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "YocoTransaction" ADD CONSTRAINT "YocoTransaction_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260223084934_add_sections/migration.sql b/backend/prisma/migrations/20260223084934_add_sections/migration.sql new file mode 100644 index 0000000..ab10ad7 --- /dev/null +++ b/backend/prisma/migrations/20260223084934_add_sections/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "Section" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Section_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SectionOption" ( + "id" TEXT NOT NULL, + "sectionId" TEXT NOT NULL, + "eventOptionId" TEXT NOT NULL, + + CONSTRAINT "SectionOption_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Section_eventId_idx" ON "Section"("eventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SectionOption_sectionId_eventOptionId_key" ON "SectionOption"("sectionId", "eventOptionId"); + +-- AddForeignKey +ALTER TABLE "Section" ADD CONSTRAINT "Section_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SectionOption" ADD CONSTRAINT "SectionOption_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SectionOption" ADD CONSTRAINT "SectionOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260318105548_failed_atempts/migration.sql b/backend/prisma/migrations/20260318105548_failed_atempts/migration.sql new file mode 100644 index 0000000..4c0b1b5 --- /dev/null +++ b/backend/prisma/migrations/20260318105548_failed_atempts/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "failedAttempts" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "lockUntil" TIMESTAMP(3); diff --git a/backend/prisma/migrations/20260407120000_add_token_version/migration.sql b/backend/prisma/migrations/20260407120000_add_token_version/migration.sql new file mode 100644 index 0000000..5385689 --- /dev/null +++ b/backend/prisma/migrations/20260407120000_add_token_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: add tokenVersion for JWT revocation +ALTER TABLE "User" ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/backend/prisma/migrations/20260407130000_task6_schema/migration.sql b/backend/prisma/migrations/20260407130000_task6_schema/migration.sql new file mode 100644 index 0000000..405d71d --- /dev/null +++ b/backend/prisma/migrations/20260407130000_task6_schema/migration.sql @@ -0,0 +1,9 @@ +-- Add isHidden and requiresAuth to Event +ALTER TABLE "Event" ADD COLUMN "isHidden" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Event" ADD COLUMN "requiresAuth" BOOLEAN NOT NULL DEFAULT true; + +-- Add quantity to Ticket (1 ticket per option, not per qty unit) +ALTER TABLE "Ticket" ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 1; + +-- Add quantityRedeemed to TicketUsage (for partial redemption) +ALTER TABLE "TicketUsage" ADD COLUMN "quantityRedeemed" INTEGER NOT NULL DEFAULT 1; \ No newline at end of file diff --git a/backend/prisma/migrations/20260409085416_unique_phone/migration.sql b/backend/prisma/migrations/20260409085416_unique_phone/migration.sql new file mode 100644 index 0000000..b762664 --- /dev/null +++ b/backend/prisma/migrations/20260409085416_unique_phone/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[phoneNumber]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "User_phoneNumber_key" ON "User"("phoneNumber"); diff --git a/backend/prisma/migrations/20260410100648_add_scan_indexes/migration.sql b/backend/prisma/migrations/20260410100648_add_scan_indexes/migration.sql new file mode 100644 index 0000000..7c1f580 --- /dev/null +++ b/backend/prisma/migrations/20260410100648_add_scan_indexes/migration.sql @@ -0,0 +1,14 @@ +-- CreateIndex +CREATE INDEX "Ticket_eventId_idx" ON "Ticket"("eventId"); + +-- CreateIndex +CREATE INDEX "Ticket_userId_idx" ON "Ticket"("userId"); + +-- CreateIndex +CREATE INDEX "TicketUsage_ticketId_idx" ON "TicketUsage"("ticketId"); + +-- CreateIndex +CREATE INDEX "TicketUsage_scannedAt_idx" ON "TicketUsage"("scannedAt"); + +-- CreateIndex +CREATE INDEX "TicketUsage_scannedById_scannedAt_idx" ON "TicketUsage"("scannedById", "scannedAt"); diff --git a/backend/prisma/migrations/20260410115035_add_missing_fk_indexes/migration.sql b/backend/prisma/migrations/20260410115035_add_missing_fk_indexes/migration.sql new file mode 100644 index 0000000..955db05 --- /dev/null +++ b/backend/prisma/migrations/20260410115035_add_missing_fk_indexes/migration.sql @@ -0,0 +1,56 @@ +-- CreateIndex +CREATE INDEX "EventAttachment_eventId_idx" ON "EventAttachment"("eventId"); + +-- CreateIndex +CREATE INDEX "EventFormField_formId_idx" ON "EventFormField"("formId"); + +-- CreateIndex +CREATE INDEX "EventOption_eventId_idx" ON "EventOption"("eventId"); + +-- CreateIndex +CREATE INDEX "FormAnswer_responseId_idx" ON "FormAnswer"("responseId"); + +-- CreateIndex +CREATE INDEX "FormAnswer_fieldId_idx" ON "FormAnswer"("fieldId"); + +-- CreateIndex +CREATE INDEX "FormResponse_registrationId_idx" ON "FormResponse"("registrationId"); + +-- CreateIndex +CREATE INDEX "PasswordReset_userId_idx" ON "PasswordReset"("userId"); + +-- CreateIndex +CREATE INDEX "Payment_userId_idx" ON "Payment"("userId"); + +-- CreateIndex +CREATE INDEX "Payment_registrationId_idx" ON "Payment"("registrationId"); + +-- CreateIndex +CREATE INDEX "Payment_eventId_idx" ON "Payment"("eventId"); + +-- CreateIndex +CREATE INDEX "Payment_createdAt_idx" ON "Payment"("createdAt"); + +-- CreateIndex +CREATE INDEX "Registration_userId_idx" ON "Registration"("userId"); + +-- CreateIndex +CREATE INDEX "Registration_eventId_idx" ON "Registration"("eventId"); + +-- CreateIndex +CREATE INDEX "Registration_userId_status_idx" ON "Registration"("userId", "status"); + +-- CreateIndex +CREATE INDEX "Registration_eventId_status_idx" ON "Registration"("eventId", "status"); + +-- CreateIndex +CREATE INDEX "RegistrationOption_registrationId_idx" ON "RegistrationOption"("registrationId"); + +-- CreateIndex +CREATE INDEX "RegistrationOption_eventOptionId_idx" ON "RegistrationOption"("eventOptionId"); + +-- CreateIndex +CREATE INDEX "Ticket_registrationOptionId_idx" ON "Ticket"("registrationOptionId"); + +-- CreateIndex +CREATE INDEX "YocoTransaction_paymentId_idx" ON "YocoTransaction"("paymentId"); diff --git a/backend/prisma/migrations/20260412150501_notification_preference/migration.sql b/backend/prisma/migrations/20260412150501_notification_preference/migration.sql new file mode 100644 index 0000000..a4fa33c --- /dev/null +++ b/backend/prisma/migrations/20260412150501_notification_preference/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "NotificationPreference" AS ENUM ('email', 'whatsapp', 'both'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "notificationPreference" "NotificationPreference" NOT NULL DEFAULT 'email'; diff --git a/backend/prisma/migrations/20260413064828_add_app_setting/migration.sql b/backend/prisma/migrations/20260413064828_add_app_setting/migration.sql new file mode 100644 index 0000000..2b8e8a3 --- /dev/null +++ b/backend/prisma/migrations/20260413064828_add_app_setting/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "AppSetting" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key") +); diff --git a/backend/prisma/migrations/20260416000000_add_variants_stock_limits/migration.sql b/backend/prisma/migrations/20260416000000_add_variants_stock_limits/migration.sql new file mode 100644 index 0000000..e248019 --- /dev/null +++ b/backend/prisma/migrations/20260416000000_add_variants_stock_limits/migration.sql @@ -0,0 +1,37 @@ +-- Add stockLimit to EventOption +ALTER TABLE "EventOption" ADD COLUMN "stockLimit" INTEGER NOT NULL DEFAULT 0; + +-- Add stockLimit to EarlyBirdTier +ALTER TABLE "EarlyBirdTier" ADD COLUMN "stockLimit" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable OptionVariant +CREATE TABLE "OptionVariant" ( + "id" TEXT NOT NULL, + "eventOptionId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "price" DOUBLE PRECISION, + "stockLimit" INTEGER NOT NULL DEFAULT 0, + "order" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "OptionVariant_pkey" PRIMARY KEY ("id") +); + +-- Add new columns to RegistrationOption +ALTER TABLE "RegistrationOption" ADD COLUMN "variantId" TEXT; +ALTER TABLE "RegistrationOption" ADD COLUMN "appliedTierId" TEXT; +ALTER TABLE "RegistrationOption" ADD COLUMN "priceSnapshot" DOUBLE PRECISION; + +-- CreateIndex +CREATE INDEX "OptionVariant_eventOptionId_idx" ON "OptionVariant"("eventOptionId"); +CREATE INDEX "RegistrationOption_variantId_idx" ON "RegistrationOption"("variantId"); +CREATE INDEX "RegistrationOption_appliedTierId_idx" ON "RegistrationOption"("appliedTierId"); + +-- AddForeignKey +ALTER TABLE "OptionVariant" ADD CONSTRAINT "OptionVariant_eventOptionId_fkey" + FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_variantId_fkey" + FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_appliedTierId_fkey" + FOREIGN KEY ("appliedTierId") REFERENCES "EarlyBirdTier"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/backend/prisma/migrations/20260417080718_add_variant_early_bird_tiers/migration.sql b/backend/prisma/migrations/20260417080718_add_variant_early_bird_tiers/migration.sql new file mode 100644 index 0000000..17a706c --- /dev/null +++ b/backend/prisma/migrations/20260417080718_add_variant_early_bird_tiers/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "EarlyBirdTier" ADD COLUMN "variantId" TEXT; + +-- CreateIndex +CREATE INDEX "EarlyBirdTier_variantId_idx" ON "EarlyBirdTier"("variantId"); + +-- AddForeignKey +ALTER TABLE "EarlyBirdTier" ADD CONSTRAINT "EarlyBirdTier_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260421072250_add_church_models/migration.sql b/backend/prisma/migrations/20260421072250_add_church_models/migration.sql new file mode 100644 index 0000000..ad8dd44 --- /dev/null +++ b/backend/prisma/migrations/20260421072250_add_church_models/migration.sql @@ -0,0 +1,213 @@ +-- CreateTable +CREATE TABLE "Ministry" ( + "id" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "image" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "order" INTEGER NOT NULL DEFAULT 0, + "leaderId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Ministry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "title" TEXT NOT NULL, + "bio" TEXT, + "photo" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "email" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContentBlock" ( + "id" TEXT NOT NULL, + "page" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "type" TEXT NOT NULL DEFAULT 'text', + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContentBlock_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChurchDocument" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "fileUrl" TEXT NOT NULL, + "category" TEXT NOT NULL DEFAULT 'other', + "isPublic" BOOLEAN NOT NULL DEFAULT true, + "uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ChurchDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FellowshipGroup" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "leader" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FellowshipGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChurchMember" ( + "id" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "mainName" TEXT NOT NULL, + "mainEmail" TEXT NOT NULL, + "mainPhone" TEXT, + "mainBirthday" TIMESTAMP(3), + "mainOccupation" TEXT, + "mainEmploymentStatus" TEXT, + "mainDateOfSalvation" TIMESTAMP(3), + "mainDateOfWaterBaptism" TIMESTAMP(3), + "mainHolySpiritBaptism" BOOLEAN NOT NULL DEFAULT false, + "spouseName" TEXT, + "spouseEmail" TEXT, + "spousePhone" TEXT, + "spouseBirthday" TIMESTAMP(3), + "spouseOccupation" TEXT, + "spouseEmploymentStatus" TEXT, + "spouseDateOfSalvation" TIMESTAMP(3), + "spouseDateOfWaterBaptism" TIMESTAMP(3), + "spouseHolySpiritBaptism" BOOLEAN, + "homeAddress" TEXT, + "inFellowshipGroup" BOOLEAN NOT NULL DEFAULT false, + "fellowshipGroupId" TEXT, + "membershipConfirmed" BOOLEAN NOT NULL DEFAULT false, + "notes" TEXT, + "appliedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "approvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ChurchMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChurchChild" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "birthday" TIMESTAMP(3), + + CONSTRAINT "ChurchChild_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MinistryInterest" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "ministryId" TEXT NOT NULL, + "forSpouse" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "MinistryInterest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Donation" ( + "id" TEXT NOT NULL, + "donorName" TEXT, + "donorEmail" TEXT, + "donorPhone" TEXT, + "amount" DOUBLE PRECISION NOT NULL, + "method" TEXT NOT NULL, + "reference" TEXT, + "note" TEXT, + "isAnonymous" BOOLEAN NOT NULL DEFAULT false, + "linkedMemberId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Donation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Ministry_slug_key" ON "Ministry"("slug"); + +-- CreateIndex +CREATE INDEX "Ministry_isActive_order_idx" ON "Ministry"("isActive", "order"); + +-- CreateIndex +CREATE INDEX "Ministry_leaderId_idx" ON "Ministry"("leaderId"); + +-- CreateIndex +CREATE INDEX "TeamMember_isActive_order_idx" ON "TeamMember"("isActive", "order"); + +-- CreateIndex +CREATE INDEX "ContentBlock_page_idx" ON "ContentBlock"("page"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContentBlock_page_key_key" ON "ContentBlock"("page", "key"); + +-- CreateIndex +CREATE INDEX "FellowshipGroup_isActive_order_idx" ON "FellowshipGroup"("isActive", "order"); + +-- CreateIndex +CREATE INDEX "ChurchMember_status_idx" ON "ChurchMember"("status"); + +-- CreateIndex +CREATE INDEX "ChurchMember_mainEmail_idx" ON "ChurchMember"("mainEmail"); + +-- CreateIndex +CREATE INDEX "ChurchMember_fellowshipGroupId_idx" ON "ChurchMember"("fellowshipGroupId"); + +-- CreateIndex +CREATE INDEX "ChurchChild_memberId_idx" ON "ChurchChild"("memberId"); + +-- CreateIndex +CREATE INDEX "MinistryInterest_memberId_idx" ON "MinistryInterest"("memberId"); + +-- CreateIndex +CREATE INDEX "MinistryInterest_ministryId_idx" ON "MinistryInterest"("ministryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MinistryInterest_memberId_ministryId_forSpouse_key" ON "MinistryInterest"("memberId", "ministryId", "forSpouse"); + +-- CreateIndex +CREATE INDEX "Donation_linkedMemberId_idx" ON "Donation"("linkedMemberId"); + +-- CreateIndex +CREATE INDEX "Donation_createdAt_idx" ON "Donation"("createdAt"); + +-- CreateIndex +CREATE INDEX "Donation_method_idx" ON "Donation"("method"); + +-- AddForeignKey +ALTER TABLE "Ministry" ADD CONSTRAINT "Ministry_leaderId_fkey" FOREIGN KEY ("leaderId") REFERENCES "TeamMember"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChurchMember" ADD CONSTRAINT "ChurchMember_fellowshipGroupId_fkey" FOREIGN KEY ("fellowshipGroupId") REFERENCES "FellowshipGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChurchChild" ADD CONSTRAINT "ChurchChild_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "ChurchMember"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MinistryInterest" ADD CONSTRAINT "MinistryInterest_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "ChurchMember"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MinistryInterest" ADD CONSTRAINT "MinistryInterest_ministryId_fkey" FOREIGN KEY ("ministryId") REFERENCES "Ministry"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Donation" ADD CONSTRAINT "Donation_linkedMemberId_fkey" FOREIGN KEY ("linkedMemberId") REFERENCES "ChurchMember"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260720095151_add_cashup_and_costs/migration.sql b/backend/prisma/migrations/20260720095151_add_cashup_and_costs/migration.sql new file mode 100644 index 0000000..ab6b7a1 --- /dev/null +++ b/backend/prisma/migrations/20260720095151_add_cashup_and_costs/migration.sql @@ -0,0 +1,158 @@ +/* + Warnings: + + - You are about to drop the `ChurchChild` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `ChurchDocument` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `ChurchMember` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `ContentBlock` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Donation` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `FellowshipGroup` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Ministry` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `MinistryInterest` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `TeamMember` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- CreateEnum +CREATE TYPE "CashupStatus" AS ENUM ('open', 'closed'); + +-- CreateEnum +CREATE TYPE "CashupAction" AS ENUM ('closed', 'quick_closed', 'reopened'); + +-- CreateEnum +CREATE TYPE "EventCostType" AS ENUM ('once_off', 'per_item'); + +-- DropForeignKey +ALTER TABLE "ChurchChild" DROP CONSTRAINT "ChurchChild_memberId_fkey"; + +-- DropForeignKey +ALTER TABLE "ChurchMember" DROP CONSTRAINT "ChurchMember_fellowshipGroupId_fkey"; + +-- DropForeignKey +ALTER TABLE "Donation" DROP CONSTRAINT "Donation_linkedMemberId_fkey"; + +-- DropForeignKey +ALTER TABLE "Ministry" DROP CONSTRAINT "Ministry_leaderId_fkey"; + +-- DropForeignKey +ALTER TABLE "MinistryInterest" DROP CONSTRAINT "MinistryInterest_memberId_fkey"; + +-- DropForeignKey +ALTER TABLE "MinistryInterest" DROP CONSTRAINT "MinistryInterest_ministryId_fkey"; + +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "cashupDraft" JSONB, +ADD COLUMN "cashupStatus" "CashupStatus" NOT NULL DEFAULT 'open', +ADD COLUMN "closedAt" TIMESTAMP(3), +ADD COLUMN "closedById" TEXT, +ADD COLUMN "reopenedAt" TIMESTAMP(3), +ADD COLUMN "reopenedById" TEXT; + +-- DropTable +DROP TABLE "ChurchChild"; + +-- DropTable +DROP TABLE "ChurchDocument"; + +-- DropTable +DROP TABLE "ChurchMember"; + +-- DropTable +DROP TABLE "ContentBlock"; + +-- DropTable +DROP TABLE "Donation"; + +-- DropTable +DROP TABLE "FellowshipGroup"; + +-- DropTable +DROP TABLE "Ministry"; + +-- DropTable +DROP TABLE "MinistryInterest"; + +-- DropTable +DROP TABLE "TeamMember"; + +-- CreateTable +CREATE TABLE "EventCost" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "eventOptionId" TEXT, + "label" TEXT NOT NULL, + "costType" "EventCostType" NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EventCost_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventCashup" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "action" "CashupAction" NOT NULL, + "donationsWrittenOff" DOUBLE PRECISION NOT NULL DEFAULT 0, + "totalCosts" DOUBLE PRECISION NOT NULL DEFAULT 0, + "totalExpectedRevenue" DOUBLE PRECISION NOT NULL DEFAULT 0, + "totalActualRevenue" DOUBLE PRECISION, + "notes" TEXT, + "performedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EventCashup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventCashupLine" ( + "id" TEXT NOT NULL, + "cashupId" TEXT NOT NULL, + "method" TEXT NOT NULL, + "expectedAmount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "actualAmount" DOUBLE PRECISION, + "variance" DOUBLE PRECISION, + "notes" TEXT, + + CONSTRAINT "EventCashupLine_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "EventCost_eventId_idx" ON "EventCost"("eventId"); + +-- CreateIndex +CREATE INDEX "EventCost_eventOptionId_idx" ON "EventCost"("eventOptionId"); + +-- CreateIndex +CREATE INDEX "EventCashup_eventId_createdAt_idx" ON "EventCashup"("eventId", "createdAt"); + +-- CreateIndex +CREATE INDEX "EventCashupLine_cashupId_idx" ON "EventCashupLine"("cashupId"); + +-- CreateIndex +CREATE INDEX "Event_closedById_idx" ON "Event"("closedById"); + +-- CreateIndex +CREATE INDEX "Event_reopenedById_idx" ON "Event"("reopenedById"); + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_reopenedById_fkey" FOREIGN KEY ("reopenedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventCost" ADD CONSTRAINT "EventCost_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventCost" ADD CONSTRAINT "EventCost_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventCashup" ADD CONSTRAINT "EventCashup_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventCashup" ADD CONSTRAINT "EventCashup_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventCashupLine" ADD CONSTRAINT "EventCashupLine_cashupId_fkey" FOREIGN KEY ("cashupId") REFERENCES "EventCashup"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260720133203_cashup_denominations_and_cost_method/migration.sql b/backend/prisma/migrations/20260720133203_cashup_denominations_and_cost_method/migration.sql new file mode 100644 index 0000000..96a7890 --- /dev/null +++ b/backend/prisma/migrations/20260720133203_cashup_denominations_and_cost_method/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable: rename donationsWrittenOff -> unallocatedDonationsTotal, preserving existing values +ALTER TABLE "EventCashup" RENAME COLUMN "donationsWrittenOff" TO "unallocatedDonationsTotal"; + +-- AlterTable +ALTER TABLE "EventCost" ADD COLUMN "paidFromMethod" TEXT; + +-- CreateTable +CREATE TABLE "EventCashupDenomination" ( + "id" TEXT NOT NULL, + "lineId" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "count" INTEGER NOT NULL, + + CONSTRAINT "EventCashupDenomination_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "EventCashupDenomination_lineId_idx" ON "EventCashupDenomination"("lineId"); + +-- AddForeignKey +ALTER TABLE "EventCashupDenomination" ADD CONSTRAINT "EventCashupDenomination_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260722084228_add_event_notify_recipients/migration.sql b/backend/prisma/migrations/20260722084228_add_event_notify_recipients/migration.sql new file mode 100644 index 0000000..bdd6675 --- /dev/null +++ b/backend/prisma/migrations/20260722084228_add_event_notify_recipients/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "_EventNotifyRecipients" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_EventNotifyRecipients_AB_unique" ON "_EventNotifyRecipients"("A", "B"); + +-- CreateIndex +CREATE INDEX "_EventNotifyRecipients_B_index" ON "_EventNotifyRecipients"("B"); + +-- AddForeignKey +ALTER TABLE "_EventNotifyRecipients" ADD CONSTRAINT "_EventNotifyRecipients_A_fkey" FOREIGN KEY ("A") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_EventNotifyRecipients" ADD CONSTRAINT "_EventNotifyRecipients_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..daab2b6 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,636 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum UserRole { + admin + supervisor + staff + user +} + +enum NotificationPreference { + email + whatsapp + both +} + +enum RegistrationStatus { + pending + confirmed + partial_paid + paid + cancelled +} + +enum CashupStatus { + open + closed +} + +enum CashupAction { + closed + quick_closed + reopened +} + +enum EventCostType { + once_off + per_item +} + +model User { + id String @id @default(uuid()) + name String + email String @unique + password String + createdAt DateTime @default(now()) + isActive Boolean @default(true) + phoneNumber String? @unique + updatedAt DateTime @updatedAt + role UserRole @default(user) + failedAttempts Int @default(0) + lockUntil DateTime? + tokenVersion Int @default(0) + notificationPreference NotificationPreference @default(email) + registrations Registration[] + payments Payment[] + tickets Ticket[] + ticketScans TicketUsage[] + passwordResets PasswordReset[] + + Event Event[] + FormDraft FormDraft[] + + eventsClosed Event[] @relation("EventClosedBy") + eventsReopened Event[] @relation("EventReopenedBy") + cashupsPerformed EventCashup[] + notifyForEvents Event[] @relation("EventNotifyRecipients") +} + +model Event { + id String @id @default(uuid()) + title String + description String? + startDate DateTime + endDate DateTime + registrationDeadline DateTime? + goLiveAt DateTime @default(now()) + price Float + picture String? + isActive Boolean @default(true) + isHidden Boolean @default(false) + requiresAuth Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdById String? + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + redirectUrl String? @unique + eventOptions EventOption[] + registrations Registration[] + payments Payment[] + tickets Ticket[] + attachments EventAttachment[] + form EventForm? + Section Section[] + + cashupStatus CashupStatus @default(open) + cashupDraft Json? + closedAt DateTime? + closedById String? + closedBy User? @relation("EventClosedBy", fields: [closedById], references: [id], onDelete: SetNull) + reopenedAt DateTime? + reopenedById String? + reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull) + costs EventCost[] + cashups EventCashup[] + + // Users who should receive registration/payment/daily-summary notifications for this + // event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js). + notifyRecipients User[] @relation("EventNotifyRecipients") + + @@index([createdById]) + @@index([closedById]) + @@index([reopenedById]) +} + +model EventOption { + id String @id @default(uuid()) + eventId String + name String + price Float + isMainTicket Boolean + stockLimit Int @default(0) + event Event @relation(fields: [eventId], references: [id], onDelete: Restrict) + registrationOptions RegistrationOption[] + earlyBirdTiers EarlyBirdTier[] + SectionOption SectionOption[] + variants OptionVariant[] + costs EventCost[] + + @@index([eventId]) +} + +model EarlyBirdTier { + id String @id @default(uuid()) + eventOptionId String + variantId String? + deadline DateTime + price Float + order Int @default(0) + stockLimit Int @default(0) + eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) + variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade) + registrationOptions RegistrationOption[] + + @@index([eventOptionId, deadline]) + @@index([variantId]) +} + +model OptionVariant { + id String @id @default(uuid()) + eventOptionId String + name String + price Float? + stockLimit Int @default(0) + order Int @default(0) + eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) + registrationOptions RegistrationOption[] + earlyBirdTiers EarlyBirdTier[] + + @@index([eventOptionId]) +} + +model Registration { + id String @id @default(uuid()) + userId String + eventId String + status RegistrationStatus @default(pending) + checkoutId String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + event Event @relation(fields: [eventId], references: [id], onDelete: Restrict) + registrationOptions RegistrationOption[] + payments Payment[] + formResponses FormResponse[] + formDrafts FormDraft[] + + @@index([userId]) + @@index([eventId]) + @@index([userId, status]) + @@index([eventId, status]) +} + +model RegistrationOption { + id String @id @default(uuid()) + registrationId String + eventOptionId String + quantity Int @default(1) + variantId String? + appliedTierId String? + priceSnapshot Float? + registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict) + eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict) + variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull) + appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull) + tickets Ticket[] + + @@index([registrationId]) + @@index([eventOptionId]) + @@index([variantId]) + @@index([appliedTierId]) +} + +model Payment { + id String @id @default(uuid()) + amount Float + method String + userId String + registrationId String? + eventId String? + isDonation Boolean @default(false) + externalId String? @unique + status String? + originalPaymentId String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull) + event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull) + originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull) + splitPayments Payment[] @relation("SplitPayments") + YocoTransaction YocoTransaction[] + + @@index([userId]) + @@index([registrationId]) + @@index([eventId]) + @@index([createdAt]) +} + +model Ticket { + id String @id @default(uuid()) + qrCode String @unique + registrationOptionId String + userId String + eventId String + quantity Int @default(1) + isUsed Boolean @default(false) + emailSent Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Restrict) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + event Event @relation(fields: [eventId], references: [id], onDelete: Restrict) + usages TicketUsage[] + + @@index([eventId]) + @@index([userId]) + @@index([registrationOptionId]) +} + +model TicketUsage { + id String @id @default(uuid()) + ticketId String + scannedById String + scannedAt DateTime @default(now()) + quantityRedeemed Int @default(1) + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict) + scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict) + + @@index([ticketId]) + @@index([scannedAt]) + @@index([scannedById, scannedAt]) +} + +model PasswordReset { + id String @id @default(uuid()) + userId String + token String @unique + expiresAt DateTime + used Boolean @default(false) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +model EventAttachment { + id String @id @default(uuid()) + eventId String + originalName String + filename String + mimeType String + size Int + url String + createdAt DateTime @default(now()) + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@index([eventId]) +} + +// Event registration forms +enum FormFieldType { + yes_no + text + date + numeric + statement + paragraph +} + +model EventForm { + id String @id @default(uuid()) + eventId String @unique + isRequired Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + fields EventFormField[] +} + +model EventFormField { + id String @id @default(uuid()) + formId String + type FormFieldType + label String + isRequired Boolean @default(false) + order Int @default(0) + helpText String? + options Json? + form EventForm @relation(fields: [formId], references: [id], onDelete: Cascade) + answers FormAnswer[] + + @@index([formId]) +} + +model FormResponse { + id String @id @default(uuid()) + registrationId String + createdAt DateTime @default(now()) + registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade) + answers FormAnswer[] + + @@index([registrationId]) +} + +model FormAnswer { + id String @id @default(uuid()) + responseId String + fieldId String + value String + response FormResponse @relation(fields: [responseId], references: [id], onDelete: Cascade) + field EventFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade) + + @@index([responseId]) + @@index([fieldId]) +} + +// Draft storage for in-progress attendee forms +model FormDraft { + id String @id @default(uuid()) + registrationId String + userId String + data Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([registrationId, userId]) +} + +// Stores all webhook events from Yoco. If a Payment is created (registration or donation), +// the record is marked reconciled and linked via paymentId. Otherwise, remains unreconciled. +model YocoTransaction { + id String @id @default(uuid()) + externalId String @unique // Yoco payment/event id + amount Int? + currency String? + createdDate DateTime? + checkoutId String? + methodType String? // paymentMethodDetails.type (e.g., card) + reconciled Boolean @default(false) + ignored Boolean @default(false) + paymentId String? + payment Payment? @relation(fields: [paymentId], references: [id], onDelete: SetNull) + raw Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([paymentId]) +} + +model Section { + id String @id @default(uuid()) + eventId String + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + allowedOptions SectionOption[] + + @@index([eventId]) +} + +model SectionOption { + id String @id @default(uuid()) + sectionId String + eventOptionId String + + section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade) + eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) + + @@unique([sectionId, eventOptionId]) +} + +model AppSetting { + key String @id + value String + updatedAt DateTime @updatedAt +} + +model EventCost { + id String @id @default(uuid()) + eventId String + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + eventOptionId String? + eventOption EventOption? @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) + label String + costType EventCostType + amount Float + paidFromMethod String? // 'cash' | 'card' | 'eft' | 'other' | null — null = not paid from event takings + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([eventId]) + @@index([eventOptionId]) +} + +// Append-only audit trail: one row per close / quick-close / reopen. Never updated or deleted. +model EventCashup { + id String @id @default(uuid()) + eventId String + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + action CashupAction + unallocatedDonationsTotal Float @default(0) + totalCosts Float @default(0) + totalExpectedRevenue Float @default(0) + totalActualRevenue Float? + notes String? + performedById String? + performedBy User? @relation(fields: [performedById], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + lines EventCashupLine[] + + @@index([eventId, createdAt]) +} + +model EventCashupLine { + id String @id @default(uuid()) + cashupId String + cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade) + method String + expectedAmount Float @default(0) + actualAmount Float? + variance Float? + notes String? + denominations EventCashupDenomination[] + + @@index([cashupId]) +} + +model EventCashupDenomination { + id String @id @default(uuid()) + lineId String + line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade) + value Float + count Int + + @@index([lineId]) +} + +// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ── +// +// model Ministry { +// id String @id @default(uuid()) +// slug String @unique +// title String +// description String? +// image String? +// isActive Boolean @default(true) +// order Int @default(0) +// leaderId String? +// leader TeamMember? @relation(fields: [leaderId], references: [id], onDelete: SetNull) +// interests MinistryInterest[] +// createdAt DateTime @default(now()) +// updatedAt DateTime @updatedAt +// +// @@index([isActive, order]) +// @@index([leaderId]) +// } +// +// model TeamMember { +// id String @id @default(uuid()) +// name String +// title String +// bio String? +// photo String? +// order Int @default(0) +// isActive Boolean @default(true) +// email String? +// createdAt DateTime @default(now()) +// updatedAt DateTime @updatedAt +// ministries Ministry[] +// +// @@index([isActive, order]) +// } +// +// model ContentBlock { +// id String @id @default(uuid()) +// page String +// key String +// value String +// type String @default("text") +// updatedAt DateTime @updatedAt +// +// @@unique([page, key]) +// @@index([page]) +// } +// +// model ChurchDocument { +// id String @id @default(uuid()) +// title String +// description String? +// fileUrl String +// category String @default("other") +// isPublic Boolean @default(true) +// uploadedAt DateTime @default(now()) +// updatedAt DateTime @updatedAt +// } +// +// model FellowshipGroup { +// id String @id @default(uuid()) +// name String +// description String? +// leader String? +// isActive Boolean @default(true) +// order Int @default(0) +// members ChurchMember[] +// createdAt DateTime @default(now()) +// updatedAt DateTime @updatedAt +// +// @@index([isActive, order]) +// } +// +// model ChurchMember { +// id String @id @default(uuid()) +// status String @default("pending") +// mainName String +// mainEmail String +// mainPhone String? +// mainBirthday DateTime? +// mainOccupation String? +// mainEmploymentStatus String? +// mainDateOfSalvation DateTime? +// mainDateOfWaterBaptism DateTime? +// mainHolySpiritBaptism Boolean @default(false) +// spouseName String? +// spouseEmail String? +// spousePhone String? +// spouseBirthday DateTime? +// spouseOccupation String? +// spouseEmploymentStatus String? +// spouseDateOfSalvation DateTime? +// spouseDateOfWaterBaptism DateTime? +// spouseHolySpiritBaptism Boolean? +// homeAddress String? +// inFellowshipGroup Boolean @default(false) +// fellowshipGroupId String? +// fellowshipGroup FellowshipGroup? @relation(fields: [fellowshipGroupId], references: [id], onDelete: SetNull) +// membershipConfirmed Boolean @default(false) +// notes String? +// appliedAt DateTime @default(now()) +// approvedAt DateTime? +// createdAt DateTime @default(now()) +// updatedAt DateTime @updatedAt +// children ChurchChild[] +// ministryInterests MinistryInterest[] +// donations Donation[] +// +// @@index([status]) +// @@index([mainEmail]) +// @@index([fellowshipGroupId]) +// } +// +// model ChurchChild { +// id String @id @default(uuid()) +// memberId String +// name String +// birthday DateTime? +// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade) +// +// @@index([memberId]) +// } +// +// model MinistryInterest { +// id String @id @default(uuid()) +// memberId String +// ministryId String +// forSpouse Boolean @default(false) +// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade) +// ministry Ministry @relation(fields: [ministryId], references: [id], onDelete: Cascade) +// +// @@unique([memberId, ministryId, forSpouse]) +// @@index([memberId]) +// @@index([ministryId]) +// } +// +// model Donation { +// id String @id @default(uuid()) +// donorName String? +// donorEmail String? +// donorPhone String? +// amount Float +// method String +// reference String? +// note String? +// isAnonymous Boolean @default(false) +// linkedMemberId String? +// linkedMember ChurchMember? @relation(fields: [linkedMemberId], references: [id], onDelete: SetNull) +// createdAt DateTime @default(now()) +// updatedAt DateTime @updatedAt +// +// @@index([linkedMemberId]) +// @@index([createdAt]) +// @@index([method]) +// } diff --git a/backend/scripts/migrate-env-to-db.js b/backend/scripts/migrate-env-to-db.js new file mode 100644 index 0000000..bb6d450 --- /dev/null +++ b/backend/scripts/migrate-env-to-db.js @@ -0,0 +1,197 @@ +/** + * migrate-env-to-db.js + * + * Reads settings from environment variables (.env) and writes them into the + * AppSetting table — only for keys that are NOT already set in the database. + * + * Safe to run on a live site: it never overwrites an existing DB value. + * Sensitive keys (smtp_user, smtp_pass) are AES-256-GCM encrypted before storage. + * + * Usage: + * cd backend + * node scripts/migrate-env-to-db.js + * + * Add --dry-run to preview what would be written without touching the DB. + * Add --force to overwrite existing DB values (use with caution on live data). + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const dotenv = require('dotenv'); + +// Load .env from the backend root +dotenv.config({ path: path.resolve(__dirname, '../.env') }); + +const { PrismaClient } = require('@prisma/client'); +const crypto = require('crypto'); + +const DRY_RUN = process.argv.includes('--dry-run'); +const FORCE = process.argv.includes('--force'); + +// ─── Encryption (mirrors backend/src/utils/encryption.js) ──────────────────── + +const SENTINEL = 'enc:'; + +const ENCRYPTION_KEY = (() => { + const secret = process.env.JWT_SECRET || 'default-unsafe-key-CHANGE-IN-PRODUCTION'; + if (!process.env.JWT_SECRET) { + console.warn('[warn] JWT_SECRET is not set — encrypted values will use an insecure fallback key.'); + } + return crypto.scryptSync(secret, 'hope-events-settings-aes256gcm-v1', 32); +})(); + +function encrypt(plaintext) { + if (!plaintext) return plaintext; + if (plaintext.startsWith(SENTINEL)) return plaintext; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`; +} + +const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']); + +// ─── Mapping: env var(s) → DB key ──────────────────────────────────────────── +// Each entry: { key: DB key, envVars: [...candidates in priority order], encrypt?: bool } +// The first non-empty env var found wins. + +const MAPPINGS = [ + // Organisation + { key: 'org_name', envVars: ['ORG_NAME'] }, + { key: 'org_tagline', envVars: ['ORG_TAGLINE'] }, + { key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] }, + + // Branding + { key: 'accent_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] }, + + // Notifications + { key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] }, + + // SMTP + { key: 'smtp_host', envVars: ['SMTP_HOST', 'EMAIL_HOST'] }, + { key: 'smtp_port', envVars: ['SMTP_PORT', 'EMAIL_PORT'] }, + { key: 'smtp_secure', envVars: ['SMTP_SECURE', 'EMAIL_SECURE'] }, + { key: 'smtp_from', envVars: ['MAIL_FROM', 'EMAIL_FROM'] }, + { key: 'smtp_user', envVars: ['SMTP_USER', 'EMAIL_USER'], encrypt: true }, + { key: 'smtp_pass', envVars: ['SMTP_PASS', 'EMAIL_PASS'], encrypt: true }, + + // App URLs (used in email templates) + { key: 'app_base_url', envVars: ['FRONTEND_URL', 'APP_BASE_URL'] }, +]; + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const prisma = new PrismaClient(); + + console.log(`\n${'─'.repeat(60)}`); + console.log(' Hope Events — .env → DB settings migration'); + if (DRY_RUN) console.log(' MODE: DRY RUN (no changes will be made)'); + if (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)'); + console.log(`${'─'.repeat(60)}\n`); + + try { + // Fetch all existing DB settings once + const existing = await prisma.appSetting.findMany(); + const dbMap = new Map(existing.map(r => [r.key, r.value])); + + console.log(`Found ${dbMap.size} existing setting(s) in the database.\n`); + + const toWrite = []; + const skipped = []; + const noEnvVal = []; + + for (const { key, envVars, encrypt: shouldEncrypt } of MAPPINGS) { + // Find first non-empty env var value + let rawValue = ''; + let sourceVar = ''; + for (const v of envVars) { + if (process.env[v] && process.env[v].trim()) { + rawValue = process.env[v].trim(); + sourceVar = v; + break; + } + } + + if (!rawValue) { + noEnvVal.push({ key, envVars }); + continue; + } + + const dbHasValue = dbMap.has(key) && dbMap.get(key) !== ''; + + if (dbHasValue && !FORCE) { + skipped.push({ key, reason: 'already set in DB (use --force to overwrite)' }); + continue; + } + + const storedValue = (shouldEncrypt || ENCRYPTED_KEYS.has(key)) + ? encrypt(rawValue) + : rawValue; + + toWrite.push({ key, rawValue, storedValue, sourceVar, encrypted: !!(shouldEncrypt || ENCRYPTED_KEYS.has(key)), overwriting: dbHasValue }); + } + + // ── Report what will be written ─────────────────────────────────────── + if (toWrite.length === 0) { + console.log('Nothing to write.\n'); + } else { + console.log(`Will write ${toWrite.length} setting(s):\n`); + for (const { key, rawValue, sourceVar, encrypted, overwriting } of toWrite) { + const display = encrypted ? '(encrypted)' : `"${rawValue}"`; + const flag = overwriting ? ' [OVERWRITING]' : ''; + console.log(` ✓ ${key.padEnd(28)} ← ${sourceVar} = ${display}${flag}`); + } + console.log(''); + } + + if (skipped.length > 0) { + console.log(`Skipped ${skipped.length} setting(s) (already in DB):\n`); + for (const { key, reason } of skipped) { + console.log(` · ${key.padEnd(28)} ${reason}`); + } + console.log(''); + } + + if (noEnvVal.length > 0) { + console.log(`No env value found for ${noEnvVal.length} setting(s) (nothing to migrate):\n`); + for (const { key, envVars: vars } of noEnvVal) { + console.log(` - ${key.padEnd(28)} checked: ${vars.join(', ')}`); + } + console.log(''); + } + + // ── Write ───────────────────────────────────────────────────────────── + if (DRY_RUN) { + console.log('Dry run complete — no changes made.\n'); + return; + } + + if (toWrite.length === 0) { + console.log('Nothing to do — all settings are already configured in the DB.\n'); + return; + } + + const ops = toWrite.map(({ key, storedValue }) => + prisma.appSetting.upsert({ + where: { key }, + update: { value: storedValue }, + create: { key, value: storedValue }, + }) + ); + + await prisma.$transaction(ops); + + console.log(`\n✓ Successfully wrote ${toWrite.length} setting(s) to the database.\n`); + } finally { + await prisma.$disconnect(); + } +} + +main().catch(err => { + console.error('\n[error]', err.message || err); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/scripts/seed-users.js b/backend/scripts/seed-users.js new file mode 100644 index 0000000..e490f67 --- /dev/null +++ b/backend/scripts/seed-users.js @@ -0,0 +1,134 @@ +/** + * Seed script: inserts 75 demo users (mix of roles, ~25 inactive) + * Run: node scripts/seed-users.js + */ + +const { PrismaClient } = require('@prisma/client'); +const bcrypt = require('bcryptjs'); + +const prisma = new PrismaClient(); + +const firstNames = [ + 'Liam', 'Olivia', 'Noah', 'Emma', 'James', 'Ava', 'Elijah', 'Sophia', + 'Lucas', 'Isabella', 'Mason', 'Mia', 'Logan', 'Charlotte', 'Ethan', + 'Amelia', 'Aiden', 'Harper', 'Jackson', 'Evelyn', 'Thabo', 'Zanele', + 'Sipho', 'Nomsa', 'Lebo', 'Thandeka', 'Kagiso', 'Palesa', 'Bongani', + 'Ayanda', 'Pieter', 'Anri', 'Francois', 'Elizma', 'Hendrik', 'Marius', + 'Chloé', 'Dylan', 'Amber', 'Caleb', 'Zoe', 'Hunter', 'Lily', 'Owen', + 'Grace', 'Ryan', 'Chloe', 'Nathan', 'Stella', 'Connor', 'Hazel', + 'Miles', 'Violet', 'Eli', 'Aurora', 'Isaiah', 'Luna', 'Nolan', + 'Scarlett', 'Wyatt', 'Penelope', 'Sebastian', 'Layla', 'Asher', + 'Riley', 'Oliver', 'Zoey', 'Ezra', 'Nora', 'Micah', 'Hannah', + 'Jonah', 'Leah', 'Aaron', 'Aubrey', 'Charles', +]; + +const lastNames = [ + 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', + 'Davis', 'Wilson', 'Moore', 'Taylor', 'Anderson', 'Thomas', 'Jackson', + 'White', 'Harris', 'Martin', 'Thompson', 'Robinson', 'Lewis', + 'Dlamini', 'Nkosi', 'Mokoena', 'Ndlovu', 'Mthembu', 'Zulu', 'Nxumalo', + 'du Plessis', 'van der Berg', 'Botha', 'Steyn', 'Fourie', 'Pretorius', + 'Venter', 'Coetzee', 'van Niekerk', 'Lombard', 'Janse van Rensburg', + 'Walker', 'Hall', 'Allen', 'Young', 'Hernandez', 'King', 'Wright', + 'Scott', 'Green', 'Baker', 'Adams', 'Nelson', 'Carter', 'Mitchell', + 'Perez', 'Roberts', 'Turner', 'Phillips', 'Campbell', 'Parker', 'Evans', + 'Edwards', 'Collins', 'Stewart', 'Morris', 'Reed', 'Cook', 'Morgan', +]; + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function randomPhone() { + // SA mobile format: 07x/08x xxxxxxx + const prefixes = ['071', '072', '073', '074', '076', '078', '079', '081', '082', '083', '084']; + return `${pick(prefixes)}${String(Math.floor(Math.random() * 9000000) + 1000000)}`; +} + +async function main() { + const passwordHash = await bcrypt.hash('Demo@1234', 10); + + // Role distribution: ~55 user, ~15 staff, ~5 supervisor + const rolePool = [ + ...Array(55).fill('user'), + ...Array(15).fill('staff'), + ...Array(5).fill('supervisor'), + ]; + + const notifPool = ['email', 'email', 'email', 'whatsapp', 'both']; + + const usedEmails = new Set(); + const usedPhones = new Set(); + const users = []; + + // Build 75 user records + while (users.length < 75) { + const first = pick(firstNames); + const last = pick(lastNames); + const tag = Math.floor(Math.random() * 999) + 1; + const email = `${first.toLowerCase().replace(/ /g, '')}.${last.toLowerCase().replace(/ /g, '').replace(/'/g, '')}${tag}@demo.church`; + + if (usedEmails.has(email)) continue; + usedEmails.add(email); + + // ~60% of users have a phone number + let phone = null; + if (Math.random() < 0.6) { + let attempt = randomPhone(); + let tries = 0; + while (usedPhones.has(attempt) && tries < 20) { + attempt = randomPhone(); + tries++; + } + if (!usedPhones.has(attempt)) { + usedPhones.add(attempt); + phone = attempt; + } + } + + users.push({ + name: `${first} ${last}`, + email, + password: passwordHash, + role: pick(rolePool), + isActive: true, // will override 25 below + phoneNumber: phone, + notificationPreference: pick(notifPool), + }); + } + + // Mark exactly 25 as inactive (spread across the list) + const inactiveIndexes = new Set(); + while (inactiveIndexes.size < 25) { + inactiveIndexes.add(Math.floor(Math.random() * 75)); + } + inactiveIndexes.forEach(i => { users[i].isActive = false; }); + + console.log(`Inserting ${users.length} users…`); + console.log(` Active: ${users.filter(u => u.isActive).length}`); + console.log(` Inactive: ${users.filter(u => !u.isActive).length}`); + console.log(` Roles: user=${users.filter(u => u.role === 'user').length}, staff=${users.filter(u => u.role === 'staff').length}, supervisor=${users.filter(u => u.role === 'supervisor').length}`); + + let created = 0; + let skipped = 0; + + for (const user of users) { + try { + await prisma.user.create({ data: user }); + created++; + } catch (err) { + if (err.code === 'P2002') { + skipped++; + } else { + throw err; + } + } + } + + console.log(`Done. Created: ${created}, Skipped (duplicate): ${skipped}`); + console.log('Default password for all demo users: Demo@1234'); +} + +main() + .catch(err => { console.error(err); process.exit(1); }) + .finally(() => prisma.$disconnect()); \ No newline at end of file diff --git a/backend/src/config/auth.js b/backend/src/config/auth.js new file mode 100644 index 0000000..401e33c --- /dev/null +++ b/backend/src/config/auth.js @@ -0,0 +1,25 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); + +const generateToken = (userId, role, tokenVersion = 0) => { + return jwt.sign( + { id: userId, role, tokenVersion }, + process.env.JWT_SECRET, + { expiresIn: '30d' } + ); +}; + +const hashPassword = async (password) => { + const salt = await bcrypt.genSalt(10); + return await bcrypt.hash(password, salt); +}; + +const comparePassword = async (enteredPassword, storedPassword) => { + return await bcrypt.compare(enteredPassword, storedPassword); +}; + +module.exports = { + generateToken, + hashPassword, + comparePassword, +}; \ No newline at end of file diff --git a/backend/src/config/db.js b/backend/src/config/db.js new file mode 100644 index 0000000..bd664f0 --- /dev/null +++ b/backend/src/config/db.js @@ -0,0 +1,5 @@ +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); + +module.exports = prisma; \ No newline at end of file diff --git a/backend/src/controllers/automationController.js b/backend/src/controllers/automationController.js new file mode 100644 index 0000000..2c74e8d --- /dev/null +++ b/backend/src/controllers/automationController.js @@ -0,0 +1,52 @@ +const { addJob } = require('../utils/scheduledEmails'); +const { v4: uuidv4 } = require('uuid'); + +// @desc Schedule multiple automation emails for an event +// @route POST /api/automations/schedule +// @access Private/Supervisor or Admin +async function scheduleAutomations(req, res) { + try { + const { eventId, jobs } = req.body || {}; + if (!eventId || typeof eventId !== 'string') { + return res.status(400).json({ message: 'eventId is required' }); + } + if (!Array.isArray(jobs) || jobs.length === 0) { + return res.status(400).json({ message: 'jobs must be a non-empty array' }); + } + + const created = []; + for (const j of jobs) { + if (!j || !j.scheduledAt || !j.subject || !(j.html || j.text)) continue; + const when = new Date(j.scheduledAt); + if (isNaN(when.getTime())) continue; + const payload = { + subject: String(j.subject), + html: j.html ? String(j.html) : undefined, + text: (!j.html && j.text) ? String(j.text) : (j.text ? String(j.text) : undefined), + template: 'custom', + filter: { status: undefined, attendeeIds: undefined }, + }; + if (j.promoEventId && typeof j.promoEventId === 'string') { + payload.promoEventId = j.promoEventId; + } + const rec = addJob({ + id: uuidv4(), + eventId, + createdById: req.user?.id || null, + scheduledAt: when.toISOString(), + payload, + }); + created.push(rec); + } + + if (created.length === 0) { + return res.status(400).json({ message: 'No valid jobs to schedule' }); + } + + return res.status(201).json({ message: `Scheduled ${created.length} automation job(s)`, jobs: created }); + } catch (error) { + return res.status(400).json({ message: error?.message || String(error) }); + } +} + +module.exports = { scheduleAutomations }; diff --git a/backend/src/controllers/bannerController.js b/backend/src/controllers/bannerController.js new file mode 100644 index 0000000..b1f5ead --- /dev/null +++ b/backend/src/controllers/bannerController.js @@ -0,0 +1,44 @@ +const fs = require('fs'); +const path = require('path'); + +const BANNER_FILE = path.join(__dirname, '../../data/banner.json'); + +function readBanner() { + try { + const raw = fs.readFileSync(BANNER_FILE, 'utf8'); + return JSON.parse(raw); + } catch { + return { message: '', type: 'info', liveFrom: null, liveTill: null }; + } +} + +function writeBanner(data) { + fs.writeFileSync(BANNER_FILE, JSON.stringify(data, null, 2), 'utf8'); +} + +// @desc Get the current banner +// @route GET /api/banner +// @access Public +const getBanner = (req, res) => { + res.json(readBanner()); +}; + +// @desc Set the banner +// @route POST /api/banner +// @access Supervisor / Admin +const setBanner = (req, res) => { + const { message, type, liveFrom, liveTill } = req.body; + + const allowed = ['info', 'warning', 'success', 'danger']; + const banner = { + message: typeof message === 'string' ? message.trim() : '', + type: allowed.includes(type) ? type : 'info', + liveFrom: liveFrom || null, + liveTill: liveTill || null, + }; + + writeBanner(banner); + res.json(banner); +}; + +module.exports = { getBanner, setBanner }; \ No newline at end of file diff --git a/backend/src/controllers/broadcastController.js b/backend/src/controllers/broadcastController.js new file mode 100644 index 0000000..c01f621 --- /dev/null +++ b/backend/src/controllers/broadcastController.js @@ -0,0 +1,215 @@ +const prisma = require('../config/db'); + +// Escape user-controlled strings before inserting them into HTML +function escapeHtml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Utilities shared with attendees emailing +function fmtDate(d) { + try { return new Date(d).toLocaleString(); } catch { return String(d); } +} + +function getFrontendBaseUrl() { + const base = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; + return String(base).replace(/\/$/, ''); +} + +function buildEventContext(event) { + if (!event) return { eventTitle: '', eventStart: '', eventLink: '', eventLinkHtml: '' }; + const eventTitle = event.title || ''; + const eventStart = event.startDate ? fmtDate(event.startDate) : ''; + const eventLink = `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}`; + const eventLinkHtml = `${eventLink}`; + return { eventTitle, eventStart, eventLink, eventLinkHtml }; +} + +function replacePlaceholders(str, ctx) { + if (!str) return str; + return String(str) + .replace(/\{\{\s*name\s*\}\}/g, ctx.name || '') + .replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '') + .replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '') + .replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || '')); +} + +function parseFreeformEmails(lines) { + // Supports formats: + // - email@example.com + // - Name + // - "Name" + const recipients = []; + const input = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/); + for (const raw of input) { + const s = String(raw || '').trim(); + if (!s) continue; + let name = ''; + let email = ''; + const m = s.match(/^(.*?)<\s*([^>\s]+@[^>\s]+)\s*>\s*$/); + if (m) { + name = m[1].trim().replace(/^"|"$/g, '').trim(); + email = m[2].trim(); + } else { + // If it just looks like an email, accept it + const em = s.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) ? s : ''; + if (em) email = em; else continue; + } + recipients.push({ email, name }); + } + return recipients; +} + +// @desc Preview broadcast recipients and sample +// @route POST /api/broadcasts/preview +// @access Private/Supervisor or Admin +const previewBroadcast = async (req, res) => { + try { + const { userIds, emails, eventId } = req.body || {}; + + // Resolve users + const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : []; + let users = []; + if (ids.length) { + users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, email: true } }); + } + + // Parse freeform emails + const extra = parseFreeformEmails(emails); + + // Merge and de-duplicate by email + const map = new Map(); + for (const u of users) { + const email = String(u.email || '').trim(); + if (!email) continue; + if (!map.has(email)) map.set(email, { email, name: u.name || '' }); + } + for (const r of extra) { + const email = String(r.email || '').trim(); + if (!email) continue; + if (!map.has(email)) map.set(email, { email, name: r.name || '' }); + } + const recipients = Array.from(map.values()); + + // Optionally resolve event info for link/title placeholder + let event = null; + if (eventId && typeof eventId === 'string') { + event = await prisma.event.findUnique({ where: { id: eventId } }); + } + const eventCtx = buildEventContext(event); + + return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Send broadcast now +// @route POST /api/broadcasts/send +// @access Private/Supervisor or Admin +const sendBroadcast = async (req, res) => { + try { + const { subject, html, text, userIds, emails, eventId } = req.body || {}; + if (!subject || !(html || text)) { + return res.status(400).json({ message: 'Subject and message (html or text) are required' }); + } + + // Resolve recipients similar to preview + const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : []; + let users = []; + if (ids.length) { + users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, email: true } }); + } + const extra = parseFreeformEmails(emails); + + const map = new Map(); + for (const u of users) { + const email = String(u.email || '').trim(); + if (!email) continue; + if (!map.has(email)) map.set(email, { email, name: u.name || '' }); + } + for (const r of extra) { + const email = String(r.email || '').trim(); + if (!email) continue; + if (!map.has(email)) map.set(email, { email, name: r.name || '' }); + } + const recipients = Array.from(map.values()); + if (recipients.length === 0) { + return res.status(400).json({ message: 'No valid recipients' }); + } + + // Resolve event + let event = null; + if (eventId && typeof eventId === 'string') { + event = await prisma.event.findUnique({ where: { id: eventId } }); + } + const eventCtx = buildEventContext(event); + + const { sendMail } = require('../utils/email'); + + // Send all emails in parallel instead of sequentially — critical for large recipient lists + const results = await Promise.allSettled(recipients.map(async rcpt => { + const ctxBase = { + name: rcpt.name || '', + eventTitle: eventCtx.eventTitle, + eventStart: eventCtx.eventStart, + eventLink: eventCtx.eventLink, + }; + const ctxForHtml = html ? { + name: escapeHtml(rcpt.name || ''), + eventTitle: escapeHtml(eventCtx.eventTitle), + eventStart: escapeHtml(eventCtx.eventStart), + eventLink: escapeHtml(eventCtx.eventLink), + eventLinkHtml: eventCtx.eventLinkHtml, + } : ctxBase; + const finalSubject = replacePlaceholders(subject, ctxBase); + const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined; + const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined); + await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText }); + })); + + const sent = results.filter(r => r.status === 'fulfilled').length; + results.forEach((r, i) => { + if (r.status === 'rejected') { + try { console.warn('[broadcast] failed to send to', recipients[i]?.email, r.reason?.message || r.reason); } catch {} + } + }); + + return res.json({ matched: recipients.length, sent }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Schedule a broadcast +// @route POST /api/broadcasts/schedule +// @access Private/Supervisor or Admin +const scheduleBroadcast = async (req, res) => { + try { + const { scheduledAt, subject, html, text, userIds, emails, eventId } = req.body || {}; + if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' }); + const when = new Date(scheduledAt); + if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' }); + if (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' }); + + const payload = { subject, html, text, userIds, emails, eventId }; + + const { addJob } = require('../utils/scheduledEmails'); + const created = addJob({ + broadcast: true, + scheduledAt: when.toISOString(), + createdById: req.user?.id || null, + payload, + }); + + return res.status(201).json({ message: 'Broadcast scheduled', job: created }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +module.exports = { previewBroadcast, sendBroadcast, scheduleBroadcast }; diff --git a/backend/src/controllers/cashupController.js b/backend/src/controllers/cashupController.js new file mode 100644 index 0000000..48d8641 --- /dev/null +++ b/backend/src/controllers/cashupController.js @@ -0,0 +1,186 @@ +const prisma = require('../config/db'); +const { v4: uuidv4 } = require('uuid'); +const { safeErrorMessage } = require('../utils/errorUtils'); +const { ALL_METHODS, assertEventOpen, computeEventFinancials } = require('../utils/cashupUtils'); + +// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history +// @route GET /api/cashups/event/:eventId +// @access Private/Supervisor +const getEventCashup = async (req, res) => { + try { + const financials = await computeEventFinancials(req.params.eventId); + res.json(financials); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Save in-progress reconciliation entries without closing the event +// @route PUT /api/cashups/event/:eventId/draft +// @access Private/Admin +const saveEventCashupDraft = async (req, res) => { + try { + const { eventId } = req.params; + const { lines, notes } = req.body; + + await assertEventOpen(eventId, res); + + await prisma.event.update({ + where: { id: eventId }, + data: { cashupDraft: { lines: Array.isArray(lines) ? lines : [], notes: notes || null, savedAt: new Date().toISOString() } } + }); + + res.json({ message: 'Draft saved' }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Close an event, optionally with a full per-method cashup +// @route POST /api/cashups/event/:eventId/close +// @access Private/Admin +const closeEvent = async (req, res) => { + try { + const { eventId } = req.params; + const { lines, notes } = req.body; + + await assertEventOpen(eventId, res); + + const financials = await computeEventFinancials(eventId); + const isFullCashup = Array.isArray(lines) && lines.length > 0; + + const cashupLines = isFullCashup + ? lines + .filter(l => l && ALL_METHODS.includes(l.method)) + .map(l => { + const expected = financials.expectedCashByMethod[l.method] || 0; + const denominations = l.method === 'cash' && Array.isArray(l.denominations) + ? l.denominations + .map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 })) + .filter(d => d.value > 0 && d.count > 0) + : []; + const actual = denominations.length > 0 + ? denominations.reduce((sum, d) => sum + d.value * d.count, 0) + : (l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null); + return { + id: uuidv4(), + method: l.method, + expectedAmount: expected, + actualAmount: actual, + variance: actual !== null ? actual - expected : null, + notes: l.notes || null, + denominations: denominations.length > 0 ? { create: denominations } : undefined + }; + }) + : []; + + const totalActualRevenue = isFullCashup + ? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0) + : null; + const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedCashByMethod[m], 0); + + const cashup = await prisma.eventCashup.create({ + data: { + id: uuidv4(), + eventId, + action: isFullCashup ? 'closed' : 'quick_closed', + unallocatedDonationsTotal: financials.unallocatedDonationsTotal, + totalCosts: financials.totalCosts, + totalExpectedRevenue, + totalActualRevenue, + notes: notes || null, + performedById: req.user.id, + lines: { create: cashupLines } + }, + include: { lines: { include: { denominations: true } }, performedBy: { select: { id: true, name: true, email: true } } } + }); + + await prisma.event.update({ + where: { id: eventId }, + data: { cashupStatus: 'closed', cashupDraft: null, closedAt: new Date(), closedById: req.user.id } + }); + + res.status(201).json(cashup); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Reopen a closed event (admin only) +// @route POST /api/cashups/event/:eventId/reopen +// @access Private/Admin +const reopenEvent = async (req, res) => { + try { + const { eventId } = req.params; + const { notes } = req.body; + + const event = await prisma.event.findUnique({ where: { id: eventId } }); + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + if (event.cashupStatus !== 'closed') { + res.status(400); + throw new Error('Event is not closed'); + } + + const auditRow = await prisma.eventCashup.create({ + data: { + id: uuidv4(), + eventId, + action: 'reopened', + notes: notes || null, + performedById: req.user.id + }, + include: { performedBy: { select: { id: true, name: true, email: true } } } + }); + + await prisma.event.update({ + where: { id: eventId }, + data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id } + }); + + res.status(201).json(auditRow); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Flat audit log of every close/quick-close/reopen, optionally filtered +// @route GET /api/cashups/audit +// @access Private/Supervisor +const getCashupAudit = async (req, res) => { + try { + const { eventId, from, to } = req.query; + + const where = {}; + if (eventId) where.eventId = eventId; + if (from || to) { + where.createdAt = {}; + if (from) where.createdAt.gte = new Date(from); + if (to) where.createdAt.lte = new Date(to); + } + + const rows = await prisma.eventCashup.findMany({ + where, + include: { + event: { select: { id: true, title: true } }, + performedBy: { select: { id: true, name: true, email: true } }, + lines: { include: { denominations: true } } + }, + orderBy: { createdAt: 'desc' } + }); + + res.json(rows); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +module.exports = { + getEventCashup, + saveEventCashupDraft, + closeEvent, + reopenEvent, + getCashupAudit +}; diff --git a/backend/src/controllers/costController.js b/backend/src/controllers/costController.js new file mode 100644 index 0000000..2d0dd44 --- /dev/null +++ b/backend/src/controllers/costController.js @@ -0,0 +1,177 @@ +const prisma = require('../config/db'); +const { v4: uuidv4 } = require('uuid'); +const { safeErrorMessage } = require('../utils/errorUtils'); +const { assertEventOpen, ALL_METHODS } = require('../utils/cashupUtils'); + +function normalizePaidFromMethod(value) { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + if (!ALL_METHODS.includes(value)) { + throw new Error("paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null"); + } + return value; +} + +// @desc List costs for an event +// @route GET /api/events/:eventId/costs +// @access Private/Supervisor +const getEventCosts = async (req, res) => { + try { + const costs = await prisma.eventCost.findMany({ + where: { eventId: req.params.eventId }, + include: { eventOption: { select: { id: true, name: true } } }, + orderBy: { createdAt: 'asc' } + }); + res.json(costs); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Create a cost for an event +// @route POST /api/events/:eventId/costs +// @access Private/Supervisor +const createEventCost = async (req, res) => { + try { + const { eventId } = req.params; + const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body; + + await assertEventOpen(eventId, res); + + if (!label || !String(label).trim()) { + res.status(400); + throw new Error('Label is required'); + } + if (costType !== 'once_off' && costType !== 'per_item') { + res.status(400); + throw new Error("costType must be 'once_off' or 'per_item'"); + } + const amt = parseFloat(amount); + if (!(amt >= 0)) { + res.status(400); + throw new Error('Amount must be a non-negative number'); + } + if (costType === 'per_item' && !eventOptionId) { + res.status(400); + throw new Error('eventOptionId is required for per-item costs'); + } + if (costType === 'per_item') { + const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId } }); + if (!option || option.eventId !== eventId) { + res.status(404); + throw new Error('Ticket type not found for this event'); + } + } + + const cost = await prisma.eventCost.create({ + data: { + id: uuidv4(), + eventId, + label: String(label).trim(), + costType, + amount: amt, + eventOptionId: costType === 'per_item' ? eventOptionId : null, + paidFromMethod: normalizePaidFromMethod(paidFromMethod) ?? null, + notes: notes || null + }, + include: { eventOption: { select: { id: true, name: true } } } + }); + + res.status(201).json(cost); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Update a cost +// @route PUT /api/costs/:id +// @access Private/Supervisor +const updateEventCost = async (req, res) => { + try { + const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } }); + if (!existing) { + res.status(404); + throw new Error('Cost not found'); + } + + await assertEventOpen(existing.eventId, res); + + const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body; + const nextCostType = costType !== undefined ? costType : existing.costType; + if (nextCostType !== 'once_off' && nextCostType !== 'per_item') { + res.status(400); + throw new Error("costType must be 'once_off' or 'per_item'"); + } + + const nextEventOptionId = nextCostType === 'per_item' + ? (eventOptionId !== undefined ? eventOptionId : existing.eventOptionId) + : null; + if (nextCostType === 'per_item') { + if (!nextEventOptionId) { + res.status(400); + throw new Error('eventOptionId is required for per-item costs'); + } + const option = await prisma.eventOption.findUnique({ where: { id: nextEventOptionId } }); + if (!option || option.eventId !== existing.eventId) { + res.status(404); + throw new Error('Ticket type not found for this event'); + } + } + + let nextAmount = existing.amount; + if (amount !== undefined) { + const amt = parseFloat(amount); + if (!(amt >= 0)) { + res.status(400); + throw new Error('Amount must be a non-negative number'); + } + nextAmount = amt; + } + + const nextPaidFromMethod = normalizePaidFromMethod(paidFromMethod); + + const cost = await prisma.eventCost.update({ + where: { id: req.params.id }, + data: { + label: label !== undefined ? String(label).trim() : existing.label, + costType: nextCostType, + amount: nextAmount, + eventOptionId: nextEventOptionId, + paidFromMethod: nextPaidFromMethod !== undefined ? nextPaidFromMethod : existing.paidFromMethod, + notes: notes !== undefined ? notes : existing.notes + }, + include: { eventOption: { select: { id: true, name: true } } } + }); + + res.json(cost); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Delete a cost +// @route DELETE /api/costs/:id +// @access Private/Supervisor +const deleteEventCost = async (req, res) => { + try { + const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } }); + if (!existing) { + res.status(404); + throw new Error('Cost not found'); + } + + await assertEventOpen(existing.eventId, res); + + await prisma.eventCost.delete({ where: { id: req.params.id } }); + res.json({ message: 'Cost deleted' }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +module.exports = { + getEventCosts, + createEventCost, + updateEventCost, + deleteEventCost +}; diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js new file mode 100644 index 0000000..b6ed3ce --- /dev/null +++ b/backend/src/controllers/eventController.js @@ -0,0 +1,1691 @@ +const prisma = require('../config/db'); +const { v4: uuidv4 } = require('uuid'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { assertEventOpen } = require('../utils/cashupUtils'); + +// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request +function toAbsoluteUrl(req, url) { + if (!url) return url; + try { + const raw = String(url).trim(); + if (!raw) return null; + // If already absolute + if (raw.startsWith('http://') || raw.startsWith('https://')) { + const u = new URL(raw); + // Rewrite localhost to the actual request host + if (['localhost', '127.0.0.1', '::1'].includes(u.hostname)) { + const origin = `${req.protocol}://${req.get('host')}`; + const base = new URL(origin); + u.protocol = base.protocol; + u.hostname = base.hostname; + u.port = base.port; + return u.toString(); + } + return raw; // keep as-is if already absolute and not localhost + } + // Relative path like /uploads/... => prefix with request origin + if (raw.startsWith('/')) { + return `${req.protocol}://${req.get('host')}${raw}`; + } + // Any other case, return as-is + return raw; + } catch { + return url; + } +} + +// @desc Create a new event +// @route POST /api/events +// @access Private/Admin +const createEvent = async (req, res) => { + try { + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth } = req.body; + + const data = { + id: uuidv4(), + title, + description, + startDate: new Date(startDate), + endDate: new Date(endDate), + registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null, + goLiveAt: goLiveAt ? new Date(goLiveAt) : undefined, + price: parseFloat(price), + picture, + updatedAt: new Date(), + createdById: req.user?.id || undefined, + redirectUrl, + isHidden: isHidden === true || isHidden === 'true', + requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true, + }; + + try { + const event = await prisma.event.create({ data }); + + // Automatically create a main ticket (event option) with the event price + try { + if (prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') { + await prisma.eventOption.create({ + data: { + id: uuidv4(), + eventId: event.id, + name: 'Main Ticket', + price: event.price || 0, + isMainTicket: true, + } + }); + } + } catch (e) { + // Non-fatal: event is created even if option creation fails + try { console.warn('[createEvent] Failed to auto-create main ticket option:', e?.message || e); } catch {} + } + + // Create form if provided + try { + const formInput = req.body?.form; + if (formInput && prisma && prisma.eventForm) { + const createdForm = await prisma.eventForm.create({ + data: { + id: uuidv4(), + eventId: event.id, + isRequired: !!formInput.isRequired, + } + }); + const fields = Array.isArray(formInput.fields) ? formInput.fields : []; + for (let i = 0; i < fields.length; i++) { + const f = fields[i]; + if (!f || !f.type || !f.label) continue; + await prisma.eventFormField.create({ + data: { + id: uuidv4(), + formId: createdForm.id, + type: f.type, + label: String(f.label), + isRequired: !!f.isRequired, + order: typeof f.order === 'number' ? f.order : i, + helpText: f.helpText || null, + options: f.options || undefined, + } + }); + } + } + } catch (e) { + const msg = String(e?.message || e || ''); + try { console.error('[createEvent] Failed to save form/fields:', msg); } catch {} + return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' }); + } + + return res.status(201).json(event); + } catch (err) { + // Gracefully handle environments where migration isn't applied yet + const msg = String(err?.message || ''); + if (msg.includes('Unknown argument `registrationDeadline`')) { + // @ts-ignore delete field and retry + delete data.registrationDeadline; + const event = await prisma.event.create({ data }); + return res.status(201).json(event); + } + if (msg.includes('Unknown argument `goLiveAt`')) { + // @ts-ignore delete field and retry + delete data.goLiveAt; + const event = await prisma.event.create({ data }); + return res.status(201).json(event); + } + if (msg.includes('Unknown argument `createdById`')) { + // @ts-ignore delete field and retry + delete data.createdById; + const event = await prisma.event.create({ data }); + return res.status(201).json(event); + } + if (msg.includes('Unknown argument `redirectUrl`')) { + // @ts-ignore delete field and retry + delete data.redirectUrl; + const event = await prisma.event.create({ data }); + return res.status(201).json(event); + } + throw err; + } + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Get all events +// @route GET /api/events +// @access Public +const getEvents = async (req, res) => { + try { + let events; + // Determine if Prisma Client supports EarlyBirdTier relation (i.e., migration+generate applied) + const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); + const eventOptionsInclude = canIncludeTiers ? { include: { earlyBirdTiers: true } } : true; + try { + events = await prisma.event.findMany({ + where: { + isActive: true, + isHidden: false, + goLiveAt: { lte: new Date() }, + endDate: { gte: new Date() } + }, + include: { eventOptions: eventOptionsInclude } + }); + } catch (e) { + // Fallback if goLiveAt/isHidden not available yet (pre-migration) + events = await prisma.event.findMany({ + where: { + isActive: true, + endDate: { gte: new Date() } + }, + include: { eventOptions: eventOptionsInclude } + }); + } + + // Compute isSoldOut per event: true when every option that has a stock limit + // is fully sold out. Events with no limited options are never sold out. + const withStock = await Promise.all(events.map(async ev => { + try { + const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0); + let isSoldOut = false; + if (limitedOpts.length > 0) { + const soldCounts = await Promise.all(limitedOpts.map(opt => + prisma.registrationOption.aggregate({ + where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } }, + _sum: { quantity: true }, + }).then(r => r._sum?.quantity || 0) + )); + isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit); + } + return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut }; + } catch { + return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false }; + } + })); + + res.json(withStock); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Get all events (including inactive) +// @route GET /api/events/all +// @access Private/Admin +const getAllEvents = async (req, res) => { + try { + const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); + const events = await prisma.event.findMany({ + include: { + eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true + } + }); + + const mapped = events.map(ev => ({ + ...ev, + picture: toAbsoluteUrl(req, ev.picture), + })); + + res.json(mapped); + } catch (error) { + // If prisma client doesn't support the relation include (rare), retry without it + const events = await prisma.event.findMany({ include: { eventOptions: true } }); + const mapped = events.map(ev => ({ ...ev, picture: toAbsoluteUrl(req, ev.picture) })); + res.json(mapped); + } +}; + +// @desc Get all events for staff/supervisor/admin (always includes hidden; optional past/inactive) +// @route GET /api/events/all +// @access Private +const getEventsAll = async (req, res) => { + try { + const includePast = req.query.includePast === 'true'; + const includeInactive = req.query.includeInactive === 'true'; + + const where = {}; + if (!includeInactive) where.isActive = true; + if (!includePast) where.endDate = { gte: new Date() }; + + const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); + const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function'); + const optionInclude = canIncludeTiers + ? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) } + : undefined; + const events = await prisma.event.findMany({ + where, + include: { + eventOptions: optionInclude ? { include: optionInclude } : true, + form: { include: { fields: true } } + }, + orderBy: { startDate: 'asc' } + }); + + const mapped = events.map(ev => ({ + ...ev, + picture: toAbsoluteUrl(req, ev.picture), + })); + + res.json(mapped); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Get event by ID +// @route GET /api/events/:id +// @access Public +const getEventById = async (req, res) => { + try { + const eventId = req.params.id; + // Determine if Prisma Client supports EarlyBirdTier / OptionVariant + const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); + const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function'); + const optionInclude = canIncludeTiers + ? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) } + : undefined; + const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true }; + + // Staff/supervisor/admin need the creator + notify-recipient list to populate the + // event edit screen's Notifications step; public callers don't need this. + const isStaffOrHigherForNotify = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)); + if (isStaffOrHigherForNotify) { + includeObj.createdBy = { select: { id: true, name: true, email: true } }; + includeObj.notifyRecipients = { select: { id: true, name: true, email: true, role: true } }; + } + + let event; + + // Try to include attachments via Prisma if the client has the model + const canUsePrismaAttachments = (prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function'); + if (canUsePrismaAttachments) { + try { + // @ts-ignore - runtime guard ensures this exists + includeObj.attachments = true; + event = await prisma.event.findUnique({ where: { id: eventId }, include: includeObj }); + } catch (e) { + // If relation/table does not exist (migration not deployed), fall back to fetching without attachments + try { + event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } }); + } catch (e2) { + // Notify-recipients relation not migrated yet either — fall back further + event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } }); + } + // And we will source attachments from the filesystem manifest below + } + } else { + // No model available on the Prisma client; fetch without attachments + try { + event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } }); + } catch (e) { + event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } }); + } + } + + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + + // Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin, + // who need to load inactive events for cashup, editing, etc. + const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)); + if (!isStaffOrHigher) { + if (event.isActive === false) { + res.status(404); + throw new Error('Event not found'); + } + // goLiveAt may not exist on older schemas — tolerate that read failing without + // affecting the isActive gate above. + let goLiveAt = null; + try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {} + if (goLiveAt && new Date() < goLiveAt) { + res.status(404); + throw new Error('Event not found'); + } + } + + // Resolve attachments + let attachments = []; + if (event.attachments && Array.isArray(event.attachments)) { + attachments = event.attachments; + } else { + // Fall back to manifest on disk if DB attachments are not available + try { + const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files'); + const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`); + if (fs.existsSync(manifestPath)) { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + const list = JSON.parse(raw) || []; + attachments = list; + } + } catch {} + } + + // Try include form definition if available + let form = null; + try { + if (prisma && prisma.eventForm && typeof prisma.eventForm.findUnique === 'function') { + const f = await prisma.eventForm.findUnique({ + where: { eventId: eventId }, + include: { fields: true }, + }); + if (f) { + form = { + id: f.id, + isRequired: !!f.isRequired, + fields: (f.fields || []).sort((a,b) => (a.order||0)-(b.order||0)).map(fl => ({ + id: fl.id, + type: fl.type, + label: fl.label, + isRequired: !!fl.isRequired, + order: fl.order || 0, + helpText: fl.helpText || null, + options: fl.options || null, + })) + }; + } + } + } catch {} + + // Compute availableCount per option and variant + let eventOptionsWithStock = event.eventOptions || []; + try { + if (canIncludeVariants) { + eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => { + // Total sold quantity for this option (all non-cancelled) + const soldAgg = await prisma.registrationOption.aggregate({ + where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } }, + _sum: { quantity: true } + }); + const soldCount = soldAgg._sum?.quantity || 0; + const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null; + + // Per-variant stock + const variantsWithStock = await Promise.all((opt.variants || []).map(async v => { + if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null }; + const vAgg = await prisma.registrationOption.aggregate({ + where: { variantId: v.id, registration: { status: { not: 'cancelled' } } }, + _sum: { quantity: true } + }); + const vSold = vAgg._sum?.quantity || 0; + return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) }; + })); + + return { ...opt, soldCount, availableCount, variants: variantsWithStock }; + })); + } + } catch (e) { + // Non-fatal: fall back to options without stock info + } + + const mapped = { + ...event, + eventOptions: eventOptionsWithStock, + picture: toAbsoluteUrl(req, event.picture), + attachments: (attachments || []).map(att => ({ + ...att, + url: toAbsoluteUrl(req, att.url) + })), + form, + }; + res.json(mapped); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message }); + } +}; + +// @desc Update event +// @route PUT /api/events/:id +// @access Private/Admin +const updateEvent = async (req, res) => { + try { + const event = await prisma.event.findUnique({ + where: { id: req.params.id } + }); + + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + + // A closed (cashed-up) event's pricing/structure must not change under the reconciled + // totals — same rule already enforced for payments/costs. Admin can reopen first. + await assertEventOpen(req.params.id, res); + + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth } = req.body; + + const data = { + title: title || event.title, + description: description !== undefined ? description : event.description, + startDate: startDate ? new Date(startDate) : event.startDate, + endDate: endDate ? new Date(endDate) : event.endDate, + registrationDeadline: registrationDeadline !== undefined ? (registrationDeadline ? new Date(registrationDeadline) : null) : event.registrationDeadline, + goLiveAt: goLiveAt !== undefined ? (goLiveAt ? new Date(goLiveAt) : new Date()) : (event.goLiveAt || undefined), + price: price ? parseFloat(price) : event.price, + picture: picture !== undefined ? picture : event.picture, + isActive: isActive !== undefined ? isActive : event.isActive, + isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false), + requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true), + updatedAt: new Date(), + redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl, + }; + + try { + const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data }); + + + // Upsert form if provided + try { + const formInput = req.body?.form; + if (formInput && prisma && prisma.eventForm) { + // find existing form + let existing = null; + try { existing = await prisma.eventForm.findUnique({ where: { eventId: updatedEvent.id } }); } catch {} + if (!existing) { + existing = await prisma.eventForm.create({ data: { id: uuidv4(), eventId: updatedEvent.id, isRequired: !!formInput.isRequired } }); + } else { + await prisma.eventForm.update({ where: { id: existing.id }, data: { isRequired: !!formInput.isRequired } }); + } + const formId = existing.id; + // Replace fields: simple approach — delete all and recreate + try { await prisma.eventFormField.deleteMany({ where: { formId } }); } catch {} + const fields = Array.isArray(formInput.fields) ? formInput.fields : []; + for (let i = 0; i < fields.length; i++) { + const f = fields[i]; + if (!f || !f.type || !f.label) continue; + await prisma.eventFormField.create({ + data: { + id: uuidv4(), + formId, + type: f.type, + label: String(f.label), + isRequired: !!f.isRequired, + order: typeof f.order === 'number' ? f.order : i, + helpText: f.helpText || null, + options: f.options || undefined, + } + }); + } + } + } catch (e) { + const msg = String(e?.message || e || ''); + try { console.error('[updateEvent] Failed to save form/fields:', msg); } catch {} + return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' }); + } + + return res.json(updatedEvent); + } catch (err) { + const msg = String(err?.message || ''); + if (msg.includes('Unknown argument `registrationDeadline`')) { + // @ts-ignore + delete data.registrationDeadline; + const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data }); + return res.json(updatedEvent); + } + if (msg.includes('Unknown argument `goLiveAt`')) { + // @ts-ignore + delete data.goLiveAt; + const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data }); + return res.json(updatedEvent); + } + throw err; + } + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Get the list of users who receive registration/payment/daily-summary +// notifications for this event. Lightweight — selects only id/name/email/role, +// skipping the option/stock computation that GET /api/events/:id does, so the +// Notifications step in the event editor loads instantly. +// @route GET /api/events/:id/notify-recipients +// @access Private/Supervisor +const getEventNotifyRecipients = async (req, res) => { + try { + const event = await prisma.event.findUnique({ + where: { id: req.params.id }, + select: { notifyRecipients: { select: { id: true, name: true, email: true, role: true } } }, + }); + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + res.json(event.notifyRecipients); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message }); + } +}; + +// @desc Set the list of users who receive registration/payment/daily-summary +// notifications for this event. Empty list falls back to the event creator. +// @route PUT /api/events/:id/notify-recipients +// @access Private/Supervisor +const updateEventNotifyRecipients = async (req, res) => { + try { + const { userIds } = req.body; + if (!Array.isArray(userIds)) { + res.status(400); + throw new Error('userIds must be an array'); + } + + const event = await prisma.event.findUnique({ where: { id: req.params.id } }); + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + + const updated = await prisma.event.update({ + where: { id: req.params.id }, + data: { notifyRecipients: { set: userIds.map(id => ({ id })) } }, + include: { notifyRecipients: { select: { id: true, name: true, email: true, role: true } } }, + }); + + res.json(updated.notifyRecipients); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message }); + } +}; + +// @desc Delete event (set inactive) +// @route DELETE /api/events/:id +// @access Private/Admin +const deleteEvent = async (req, res) => { + try { + const event = await prisma.event.findUnique({ + where: { id: req.params.id } + }); + + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + + // Instead of deleting, we set isActive to false + await prisma.event.update({ + where: { id: req.params.id }, + data: { + isActive: false, + updatedAt: new Date() + } + }); + + res.json({ message: 'Event deactivated' }); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Create event option +// @route POST /api/events/:id/options +// @access Private/Admin +const createEventOption = async (req, res) => { + try { + const event = await prisma.event.findUnique({ + where: { id: req.params.id } + }); + + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + + await assertEventOpen(req.params.id, res); + + const { name, price, isMainTicket, stockLimit } = req.body; + + const eventOption = await prisma.eventOption.create({ + data: { + id: uuidv4(), + eventId: req.params.id, + name, + price: parseFloat(price), + isMainTicket: isMainTicket || false, + stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0, + } + }); + + res.status(201).json(eventOption); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Update event option +// @route PUT /api/events/options/:id +// @access Private/Admin +const updateEventOption = async (req, res) => { + try { + const eventOption = await prisma.eventOption.findUnique({ + where: { id: req.params.id }, + include: { earlyBirdTiers: true } + }); + + if (!eventOption) { + res.status(404); + throw new Error('Event option not found'); + } + + await assertEventOpen(eventOption.eventId, res); + + const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants } = req.body; + + const updatedEventOption = await prisma.eventOption.update({ + where: { id: req.params.id }, + data: { + name: name !== undefined ? name : eventOption.name, + price: price !== undefined ? parseFloat(price) : eventOption.price, + isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket, + stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit, + } + }); + + // If earlyBirdTiers provided, replace all tiers for this option (including per-variant ones) + if (Array.isArray(earlyBirdTiers)) { + try { await prisma.earlyBirdTier.deleteMany({ where: { eventOptionId: updatedEventOption.id } }); } catch {} + for (let i = 0; i < earlyBirdTiers.length; i++) { + const t = earlyBirdTiers[i]; + if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue; + const deadline = new Date(t.deadline); + const p = parseFloat(t.price); + if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue; + await prisma.earlyBirdTier.create({ + data: { + id: require('uuid').v4(), + eventOptionId: updatedEventOption.id, + variantId: t.variantId || null, + deadline, + price: p, + order: typeof t.order === 'number' ? t.order : i, + stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0, + } + }); + } + } + + // If variants array provided, upsert variants + if (Array.isArray(variants)) { + const incomingIds = variants.filter(v => v.id).map(v => v.id); + // Delete variants not in the new list (only if they have no registrations) + const existingVariants = await prisma.optionVariant.findMany({ where: { eventOptionId: updatedEventOption.id } }); + for (const ev of existingVariants) { + if (!incomingIds.includes(ev.id)) { + const usageCount = await prisma.registrationOption.count({ where: { variantId: ev.id } }); + if (usageCount === 0) { + await prisma.optionVariant.delete({ where: { id: ev.id } }); + } + } + } + // Upsert incoming variants + for (let i = 0; i < variants.length; i++) { + const v = variants[i]; + if (!v.name) continue; + const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null; + const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0; + if (v.id) { + await prisma.optionVariant.update({ + where: { id: v.id }, + data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, order: i } + }); + } else { + await prisma.optionVariant.create({ + data: { + id: require('uuid').v4(), + eventOptionId: updatedEventOption.id, + name: v.name, + price: variantPrice, + stockLimit: variantStockLimit, + order: i, + } + }); + } + } + } + + const refreshed = await prisma.eventOption.findUnique({ + where: { id: updatedEventOption.id }, + include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } + }); + + res.json(refreshed); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Delete event option +// @route DELETE /api/events/options/:id +// @access Private/Admin +const deleteEventOption = async (req, res) => { + try { + const eventOption = await prisma.eventOption.findUnique({ + where: { id: req.params.id } + }); + + if (!eventOption) { + res.status(404); + throw new Error('Event option not found'); + } + + await assertEventOpen(eventOption.eventId, res); + + // If deleting a main ticket, ensure at least one other main ticket exists for the same event + if (eventOption.isMainTicket) { + const otherMainCount = await prisma.eventOption.count({ + where: { + eventId: eventOption.eventId, + isMainTicket: true, + NOT: { id: eventOption.id }, + } + }); + if (otherMainCount === 0) { + res.status(400); + throw new Error('Cannot delete the only main ticket option'); + } + } + + // Check if there are any registrations using this option + const registrationOptions = await prisma.registrationOption.findMany({ + where: { eventOptionId: req.params.id } + }); + + if (registrationOptions.length > 0) { + res.status(400); + throw new Error('Cannot delete event option that has registrations'); + } + + await prisma.eventOption.delete({ + where: { id: req.params.id } + }); + + res.json({ message: 'Event option removed' }); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Create a variant for an event option +// @route POST /api/events/options/:id/variants +// @access Private/Supervisor +const createOptionVariant = async (req, res) => { + try { + const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } }); + if (!eventOption) { res.status(404); throw new Error('Event option not found'); } + await assertEventOpen(eventOption.eventId, res); + const { name, price, stockLimit, order } = req.body; + if (!name) { res.status(400); throw new Error('Variant name is required'); } + const variant = await prisma.optionVariant.create({ + data: { + id: uuidv4(), + eventOptionId: req.params.id, + name, + price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null, + stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0, + order: order !== undefined ? parseInt(order, 10) : 0, + } + }); + res.status(201).json(variant); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message }); + } +}; + +// @desc Update a variant +// @route PUT /api/events/variants/:id +// @access Private/Supervisor +const updateOptionVariant = async (req, res) => { + try { + const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } }); + if (!variant) { res.status(404); throw new Error('Variant not found'); } + await assertEventOpen(variant.eventOption.eventId, res); + const { name, price, stockLimit, order } = req.body; + const updated = await prisma.optionVariant.update({ + where: { id: req.params.id }, + data: { + name: name !== undefined ? name : variant.name, + price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price, + stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit, + order: order !== undefined ? parseInt(order, 10) : variant.order, + } + }); + res.json(updated); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message }); + } +}; + +// @desc Delete a variant +// @route DELETE /api/events/variants/:id +// @access Private/Admin +const deleteOptionVariant = async (req, res) => { + try { + const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } }); + if (!variant) { res.status(404); throw new Error('Variant not found'); } + await assertEventOpen(variant.eventOption.eventId, res); + const usageCount = await prisma.registrationOption.count({ where: { variantId: req.params.id } }); + if (usageCount > 0) { res.status(400); throw new Error('Cannot delete variant with existing registrations'); } + await prisma.optionVariant.delete({ where: { id: req.params.id } }); + res.json({ message: 'Variant deleted' }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message }); + } +}; + +// File upload setup for attachments (PDFs/docs) +const attachmentsStorage = multer.diskStorage({ + destination: function (req, file, cb) { + const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files'); + try { + if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true }); + cb(null, uploadPath); + } catch (err) { + cb(err); + } + }, + filename: function (req, file, cb) { + const unique = `${Date.now()}-${file.originalname}`; + cb(null, unique); + } +}); + +const allowedDocs = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip']; +const uploadAttachmentMulter = multer({ + storage: attachmentsStorage, + limits: { fileSize: 20 * 1024 * 1024 }, // 20MB + fileFilter: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (!allowedDocs.includes(ext)) { + return cb(new Error('Unsupported file type')); + } + cb(null, true); + } +}); + +// @desc List attachments for an event +// @route GET /api/events/:id/attachments +// @access Private/Supervisor (for dashboard) and could be public via event details +const listEventAttachments = async (req, res) => { + try { + const eventId = req.params.id; + // If attachments model isn't available yet (pre-migration/generate), fall back to filesystem manifest + if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function')) { + try { + const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files'); + const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`); + if (!fs.existsSync(manifestPath)) return res.json([]); + const raw = fs.readFileSync(manifestPath, 'utf-8'); + const list = JSON.parse(raw); + return res.json(list.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) }))); + } catch (e) { + // If manifest is corrupt or unreadable, return empty list rather than failing + return res.json([]); + } + } + const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } }); + res.json(items.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) }))); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Upload attachment for an event +// @route POST /api/events/:id/attachments +// @access Private/Supervisor +const uploadEventAttachment = [ + (req, res, next) => uploadAttachmentMulter.single('file')(req, res, (err) => { + if (err) req.multerError = err; + next(); + }), + async (req, res) => { + try { + if (req.multerError) throw req.multerError; + if (!req.file) return res.status(400).json({ message: 'No file uploaded' }); + const eventId = req.params.id; + const relUrl = `/uploads/event-files/${req.file.filename}`; + + // Fallback: if attachments model is not available yet, store in filesystem manifest + if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.create === 'function')) { + try { console.warn('[attachments] Falling back to filesystem manifest: Prisma client lacks EventAttachment model. Ensure prisma generate ran in the deployed app dir and server restarted.'); } catch {} + try { + const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`); + const entry = { + id: uuidv4(), + eventId, + originalName: req.file.originalname, + filename: req.file.filename, + mimeType: req.file.mimetype, + size: req.file.size, + url: relUrl, + createdAt: new Date().toISOString() + }; + let list = []; + if (fs.existsSync(manifestPath)) { + try { + list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || []; + } catch {} + } + list.unshift(entry); + fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8'); + return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) }); + } catch (e) { + return res.status(500).json({ message: 'Failed to record attachment', detail: e?.message }); + } + } + + // Normal path: DB-backed + try { + const created = await prisma.eventAttachment.create({ + data: { + id: uuidv4(), + eventId, + originalName: req.file.originalname, + filename: req.file.filename, + mimeType: req.file.mimetype, + size: req.file.size, + url: relUrl, + } + }); + return res.status(201).json({ ...created, url: toAbsoluteUrl(req, created.url) }); + } catch (e) { + const msg = String(e?.message || e || ''); + const isMissingTable = msg.includes('does not exist') || msg.includes('relation') || msg.includes('P2021'); + if (isMissingTable) { + try { console.warn('[attachments] DB create failed, falling back to filesystem manifest. Error:', msg); } catch {} + try { + const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`); + const entry = { + id: uuidv4(), + eventId, + originalName: req.file.originalname, + filename: req.file.filename, + mimeType: req.file.mimetype, + size: req.file.size, + url: relUrl, + createdAt: new Date().toISOString() + }; + let list = []; + if (fs.existsSync(manifestPath)) { + try { list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || []; } catch {} + } + list.unshift(entry); + fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8'); + return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) }); + } catch (fallbackErr) { + return res.status(500).json({ message: 'Failed to record attachment', detail: String(fallbackErr?.message || fallbackErr) }); + } + } + // Unknown error + throw e; + } + } catch (error) { + res.status(400).json({ message: error.message }); + } + } +]; + +// @desc Delete attachment +// @route DELETE /api/events/:eventId/attachments/:attachmentId +// @access Private/Admin or Supervisor +const deleteEventAttachment = async (req, res) => { + try { + const { eventId, attachmentId } = req.params; + // If attachments model is not available yet, fall back to filesystem manifest + if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findUnique === 'function')) { + try { + const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files'); + const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`); + if (!fs.existsSync(manifestPath)) return res.status(404).json({ message: 'Attachment not found' }); + const list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || []; + const idx = list.findIndex((x) => x.id === attachmentId); + if (idx === -1) return res.status(404).json({ message: 'Attachment not found' }); + const att = list[idx]; + // Remove file on disk (best-effort) + try { + const filePath = path.join(uploadDir, att.filename); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch {} + // Remove from manifest + list.splice(idx, 1); + fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8'); + return res.json({ message: 'Attachment deleted' }); + } catch (e) { + return res.status(500).json({ message: 'Failed to delete attachment', detail: e?.message }); + } + } + const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } }); + if (!att || att.eventId !== eventId) { + return res.status(404).json({ message: 'Attachment not found' }); + } + // Remove file on disk (best-effort) + try { + const filePath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files', att.filename); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch {} + await prisma.eventAttachment.delete({ where: { id: attachmentId } }); + res.json({ message: 'Attachment deleted' }); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Diagnostics for attachments pipeline +// @route GET /api/events/attachments/status +// @access Private/Admin +const attachmentsStatus = async (req, res) => { + try { + const hasModel = !!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function'); + let tableQueryable = false; + let probeError = null; + if (hasModel) { + try { + await prisma.eventAttachment.findFirst({}); + tableQueryable = true; + } catch (e) { + probeError = String(e?.message || e); + } + } + // Mask DB URL to avoid leaking secrets + const dbUrl = process.env.DATABASE_URL || ''; + let dbInfo = null; + try { + const u = new URL(dbUrl); + dbInfo = { + protocol: u.protocol.replace(':',''), + host: u.hostname, + port: u.port || undefined, + database: (u.pathname || '').replace(/^\//,'') || undefined, + }; + } catch {} + res.json({ + prismaClientHasEventAttachmentModel: hasModel, + databaseTableQueryable: tableQueryable, + probeError, + database: dbInfo, + }); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Admin-triggered sync: import manifest files into DB +// @route POST /api/events/attachments/sync +// @access Private/Admin +const { syncManifestsToDb } = require('../utils/attachmentsSync'); +const attachmentsSync = async (req, res) => { + try { + const dryRun = String(req.query.dryRun || '').toLowerCase() === 'true'; + const remove = String(req.query.remove || '').toLowerCase() === 'true'; + const summary = await syncManifestsToDb(prisma, { dryRun, removeManifestAfterImport: remove }); + res.json({ dryRun, remove, ...summary }); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Email attendees for an event with filters and preview support +// @route POST /api/events/:id/email-attendees +// @access Private/Supervisor or Admin +const emailEventAttendees = async (req, res) => { + try { + const eventId = req.params.id; + const { subject, html, text, filter, dryRun, template } = req.body || {}; + + // For non-ticket templates or custom messages, require subject and content + if ((template !== 'tickets') && (!subject || !(html || text))) { + res.status(400); + throw new Error('Subject and message (html or text) are required unless template is "tickets"'); + } + + // Ensure event exists + const event = await prisma.event.findUnique({ where: { id: eventId } }); + if (!event) { res.status(404); throw new Error('Event not found'); } + + // Optional promo event (used by Automations: Next Event Promo) + let promoEvent = null; + try { + const promoEventId = req.body?.promoEventId; + if (promoEventId && typeof promoEventId === 'string') { + promoEvent = await prisma.event.findUnique({ where: { id: promoEventId } }); + } + } catch {} + + // Build where clause for registrations + const where = { eventId }; + + // Filter by registration status groups: paid, unpaid, partial_paid, cancelled, any + const statusFilter = (filter && filter.status) ? String(filter.status) : 'any'; + if (statusFilter === 'paid') { + where.status = 'paid'; + } else if (statusFilter === 'unpaid') { + where.status = { in: ['pending', 'partial_paid'] }; + } else if (statusFilter === 'partial_paid') { + where.status = 'partial_paid'; + } else if (statusFilter === 'cancelled') { + where.status = 'cancelled'; + } + + // Optional explicit attendee selection by userIds + const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : []; + if (attendeeIds.length > 0) { + // Prefer server-side filtering when possible + where.userId = { in: attendeeIds }; + } + + // Name/email substring filter (kept for backward compatibility) + const nameQ = (filter && filter.name) ? String(filter.name).trim() : ''; + const emailQ = (filter && filter.email) ? String(filter.email).trim() : ''; + + const registrations = await prisma.registration.findMany({ + where, + include: { + user: { select: { id: true, name: true, email: true, isActive: true } }, + // Include options with eventOption and early-bird tiers so pricing/balances compute correctly + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true, + }, + orderBy: { createdAt: 'asc' } + }); + + // Apply name/email filter in JS (Prisma cross-relational OR contains with guards varies across versions) + // Also exclude guest/system-generated emails and suspended users + let filtered = registrations.filter(r => { + if (!r.user || !r.user.email) return false; + if (r.user.email.endsWith('@guest.local')) return false; + if (r.user.isActive === false) return false; + return true; + }); + if (nameQ) { + const q = nameQ.toLowerCase(); + filtered = filtered.filter(r => (r.user?.name || '').toLowerCase().includes(q)); + } + if (emailQ) { + const q = emailQ.toLowerCase(); + filtered = filtered.filter(r => (r.user?.email || '').toLowerCase().includes(q)); + } + + // Unique recipients by email to avoid duplicates across multiple registrations + const uniqMap = new Map(); + for (const r of filtered) { + const email = (r.user?.email || '').trim(); + if (!email) continue; + if (!uniqMap.has(email)) { + uniqMap.set(email, { email, name: r.user?.name || '' }); + } + } + const recipients = Array.from(uniqMap.values()); + + if (dryRun) { + return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) }); + } + + const { sendMail } = require('../utils/email'); + const { computeRegistrationTotalDue } = require('../utils/pricing'); + + function fmtAmount(amt) { + const n = Number(amt || 0); + return `R${n.toFixed(2)}`; + } + function fmtDate(d) { + try { return new Date(d).toLocaleString(); } catch { return String(d); } + } + function replacePlaceholders(str, ctx) { + if (!str) return str; + return String(str) + .replace(/\{\{\s*name\s*\}\}/g, ctx.name || '') + .replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '') + .replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '') + .replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || '')) + .replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '') + .replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || '')) + .replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || ''); + } + + // Build per-recipient registration aggregates for this event + const regsByEmail = new Map(); + for (const r of registrations) { + const em = (r.user?.email || '').trim(); + if (!em) continue; + if (!regsByEmail.has(em)) regsByEmail.set(em, []); + regsByEmail.get(em).push(r); + } + + let sent = 0; + + // Special handling for tickets template: trigger ticket emails containing attachments + if (template === 'tickets') { + const { emailTickets } = require('./ticketController'); + for (const rcpt of recipients) { + try { + const regs = regsByEmail.get(rcpt.email) || []; + // Send tickets for each registration that belongs to this recipient for this event + for (const reg of regs) { + // Use the controller helper as in other parts of the code + const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } }; + const mockRes = { status: () => mockRes, json: () => {} }; + await emailTickets(mockReq, mockRes); + } + sent++; + } catch (e) { + try { console.warn('[email-attendees tickets] Failed for', rcpt.email, e?.message || e); } catch {} + } + } + return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' }); + } + + const eventTitle = event?.title || 'the event'; + const eventStart = event?.startDate ? fmtDate(event.startDate) : ''; + // Build event and promo links for placeholders + const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); + const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`; + const eventLinkHtml = `${eventLink}`; + const promoTitle = promoEvent?.title || ''; + const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : ''; + const promoLinkHtml = promoLink ? `${promoLink}` : ''; + + // Send individually with personalization + for (const rcpt of recipients) { + try { + const regs = regsByEmail.get(rcpt.email) || []; + // Sum outstanding balance across this user's registrations for the event + let totalDue = 0; let totalPaid = 0; + for (const r of regs) { + const due = computeRegistrationTotalDue(r, new Date()); + const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + totalDue += due; totalPaid += paid; + } + const balance = Math.max(totalDue - totalPaid, 0); + // Build context for placeholder replacement + const ctxBase = { + name: rcpt.name || '', + eventTitle, + eventStart, + eventLink, + promoTitle, + promoLink, + balance, + balanceFmt: fmtAmount(balance), + }; + const ctxHtml = { + ...ctxBase, + eventLinkHtml, + promoLinkHtml, + }; + + // If a known template is selected and no explicit content provided, auto-build content + let subj = subject; + let h = html; let t = text; + if (!h && !t && template) { + if (template === 'payment_reminder') { + subj = `Payment reminder: ${eventTitle}`; + t = `Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`; + h = ` +
+

Payment reminder

+

Hi {{name}},

+

You have an outstanding balance of {{balance}} for {{event.title}}.

+ ${eventStart ? `

Event starts: {{event.start}}

` : ''} +

Please settle your balance to secure your tickets. Thank you!

+
`; + } else if (template === 'event_reminder') { + subj = `Reminder: ${eventTitle} on ${eventStart || ''}`.trim(); + t = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`; + h = ` +
+

Event reminder

+

Hi {{name}},

+

This is a reminder for {{event.title}}.

+ ${eventStart ? `

Start: {{event.start}}

` : ''} +

We look forward to seeing you!

+
`; + } + } + + // Always perform placeholder replacement on whatever we have + const finalSubject = replacePlaceholders(subj || '', ctxBase); + const finalHtml = h ? replacePlaceholders(h, ctxHtml) : undefined; + const finalText = (!h ? replacePlaceholders(t || '', ctxBase) : undefined); + + await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText }); + sent++; + } catch (e) { + try { console.warn('[email-attendees] Failed for', rcpt.email, e?.message || e); } catch {} + } + } + + return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Send WhatsApp message to event attendees with filters and preview support +// @route POST /api/events/:id/whatsapp-attendees +// @access Private/Supervisor or Admin +const whatsappEventAttendees = async (req, res) => { + try { + const eventId = req.params.id; + const { message, filter, dryRun, template } = req.body || {}; + + if (template !== 'tickets' && !message) { + res.status(400); + throw new Error('message is required unless template is "tickets"'); + } + + const event = await prisma.event.findUnique({ where: { id: eventId } }); + if (!event) { res.status(404); throw new Error('Event not found'); } + + const where = { eventId }; + const statusFilter = (filter && filter.status) ? String(filter.status) : 'any'; + if (statusFilter === 'paid') { + where.status = 'paid'; + } else if (statusFilter === 'unpaid') { + where.status = { in: ['pending', 'partial_paid'] }; + } else if (statusFilter === 'partial_paid') { + where.status = 'partial_paid'; + } else if (statusFilter === 'cancelled') { + where.status = 'cancelled'; + } + + const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : []; + if (attendeeIds.length > 0) { + where.userId = { in: attendeeIds }; + } + + const nameQ = (filter && filter.name) ? String(filter.name).trim() : ''; + const phoneQ = (filter && filter.phone) ? String(filter.phone).trim() : ''; + + const registrations = await prisma.registration.findMany({ + where, + include: { + user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true, + }, + orderBy: { createdAt: 'asc' } + }); + + const { isValidZAPhone, normalizeZAPhone } = require('../utils/whatsapp'); + + let filtered = registrations.filter(r => { + if (!r.user) return false; + if (r.user.email && r.user.email.endsWith('@guest.local')) return false; + if (r.user.isActive === false) return false; + if (!isValidZAPhone(r.user.phoneNumber)) return false; + return true; + }); + + if (nameQ) { + const q = nameQ.toLowerCase(); + filtered = filtered.filter(r => (r.user?.name || '').toLowerCase().includes(q)); + } + if (phoneQ) { + filtered = filtered.filter(r => (r.user?.phoneNumber || '').includes(phoneQ)); + } + + // Unique recipients by phone number + const uniqMap = new Map(); + for (const r of filtered) { + const phone = normalizeZAPhone(r.user?.phoneNumber); + if (!phone) continue; + if (!uniqMap.has(phone)) { + uniqMap.set(phone, { phone, name: r.user?.name || '', userId: r.user?.id, registrationId: r.id }); + } + } + const recipients = Array.from(uniqMap.values()); + + if (dryRun) { + return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) }); + } + + const { sendText } = require('../utils/whatsapp'); + const { computeRegistrationTotalDue } = require('../utils/pricing'); + + function fmtAmount(amt) { + const n = Number(amt || 0); + return `R${n.toFixed(2)}`; + } + function fmtDate(d) { + try { return new Date(d).toLocaleString(); } catch { return String(d); } + } + function replacePlaceholders(str, ctx) { + if (!str) return str; + return String(str) + .replace(/\{\{\s*name\s*\}\}/g, ctx.name || '') + .replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '') + .replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '') + .replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '') + .replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || ''); + } + + const regsByPhone = new Map(); + for (const r of registrations) { + const phone = normalizeZAPhone(r.user?.phoneNumber); + if (!phone) continue; + if (!regsByPhone.has(phone)) regsByPhone.set(phone, []); + regsByPhone.get(phone).push(r); + } + + const eventTitle = event?.title || 'the event'; + const eventStart = event?.startDate ? fmtDate(event.startDate) : ''; + const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); + const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`; + + let sent = 0; + + // Special handling for tickets template: send ticket PDFs via WhatsApp + if (template === 'tickets') { + const { emailTickets } = require('./ticketController'); + for (const rcpt of recipients) { + try { + const regs = regsByPhone.get(rcpt.phone) || []; + for (const reg of regs) { + const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } }; + const mockRes = { status: () => mockRes, json: () => {} }; + await emailTickets(mockReq, mockRes); + } + sent++; + } catch (e) { + try { console.warn('[whatsapp-attendees tickets] Failed for', rcpt.phone, e?.message || e); } catch {} + } + } + return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' }); + } + + for (const rcpt of recipients) { + try { + const regs = regsByPhone.get(rcpt.phone) || []; + let totalDue = 0; let totalPaid = 0; + for (const r of regs) { + const due = computeRegistrationTotalDue(r, new Date()); + const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + totalDue += due; totalPaid += paid; + } + const balance = Math.max(totalDue - totalPaid, 0); + const ctx = { + name: rcpt.name || '', + eventTitle, + eventStart, + eventLink, + balance, + balanceFmt: fmtAmount(balance), + }; + + let msg = message; + if (!msg && template === 'payment_reminder') { + msg = `Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`; + } else if (!msg && template === 'event_reminder') { + msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`; + } + + const finalMessage = replacePlaceholders(msg || '', ctx); + await sendText(rcpt.phone, finalMessage); + sent++; + } catch (e) { + try { console.warn('[whatsapp-attendees] Failed for', rcpt.phone, e?.message || e); } catch {} + } + } + + return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Schedule email to attendees at a specific date/time +// @route POST /api/events/:id/email-attendees/schedule +// @access Private/Supervisor or Admin +const scheduleEmailEventAttendees = async (req, res) => { + try { + const eventId = req.params.id; + const { scheduledAt, subject, html, text, filter, template } = req.body || {}; + + // Validate event + const event = await prisma.event.findUnique({ where: { id: eventId } }); + if (!event) { res.status(404); throw new Error('Event not found'); } + + // Validate date + if (!scheduledAt) { res.status(400); throw new Error('scheduledAt is required'); } + const when = new Date(scheduledAt); + if (isNaN(when.getTime())) { res.status(400); throw new Error('scheduledAt must be a valid ISO date-time'); } + + // For non-ticket templates or custom messages, require subject and content + if ((template !== 'tickets') && (!subject || !(html || text))) { + res.status(400); + throw new Error('Subject and message (html or text) are required unless template is "tickets"'); + } + + // Build payload matching emailEventAttendees body + const payload = { subject, html, text, filter: filter || {}, template }; + + const { addJob } = require('../utils/scheduledEmails'); + const created = addJob({ + eventId, + createdById: req.user?.id || null, + scheduledAt: when.toISOString(), + payload, + }); + + return res.status(201).json({ message: 'Email scheduled', job: created }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Schedule WhatsApp message to attendees at a specific date/time +// @route POST /api/events/:id/whatsapp-attendees/schedule +// @access Private/Supervisor or Admin +const scheduleWhatsappEventAttendees = async (req, res) => { + try { + const eventId = req.params.id; + const { scheduledAt, message, filter, template } = req.body || {}; + + const event = await prisma.event.findUnique({ where: { id: eventId } }); + if (!event) { res.status(404); throw new Error('Event not found'); } + + if (!scheduledAt) { res.status(400); throw new Error('scheduledAt is required'); } + const when = new Date(scheduledAt); + if (isNaN(when.getTime())) { res.status(400); throw new Error('scheduledAt must be a valid ISO date-time'); } + + if (template !== 'tickets' && !message) { + res.status(400); + throw new Error('message is required unless template is "tickets"'); + } + + const payload = { message, filter: filter || {}, template }; + + const { addJob } = require('../utils/scheduledEmails'); + const created = addJob({ + eventId, + channel: 'whatsapp', + createdById: req.user?.id || null, + scheduledAt: when.toISOString(), + payload, + }); + + return res.status(201).json({ message: 'WhatsApp message scheduled', job: created }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +/** + * @desc Get event by redirectUrl (alias) + * @route GET /api/events/by-alias/:redirectUrl + * @access Public + */ +const getEventByAlias = async (req, res) => { + const { redirectUrl } = req.params; + + try { + const event = await prisma.event.findFirst({ + where: { + redirectUrl: { + equals: redirectUrl, + mode: 'insensitive', + }, + isActive: true, + }, + select: { + id: true, + title: true, + description: true, + startDate: true, + endDate: true, + registrationDeadline: true, + goLiveAt: true, + price: true, + picture: true, + isActive: true, + redirectUrl: true, + }, + }); + + if (!event) { + return res.status(404).json({ message: 'Event not found or inactive' }); + } + + const now = new Date(); + const start = event.startDate ? new Date(event.startDate) : null; + const end = event.endDate ? new Date(event.endDate) : null; + const isLive = + (!start || start >= now) && + (!end || end >= now); + + res.json({ ...event, isLive }); + } catch (error) { + console.error('[getEventByAlias] Error:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +module.exports = { + createEvent, + getEvents, + getAllEvents, + getEventsAll, + getEventById, + updateEvent, + getEventNotifyRecipients, + updateEventNotifyRecipients, + deleteEvent, + createEventOption, + updateEventOption, + deleteEventOption, + createOptionVariant, + updateOptionVariant, + deleteOptionVariant, + listEventAttachments, + uploadEventAttachment, + deleteEventAttachment, + attachmentsStatus, + attachmentsSync, + emailEventAttendees, + scheduleEmailEventAttendees, + whatsappEventAttendees, + scheduleWhatsappEventAttendees, + getEventByAlias +}; \ No newline at end of file diff --git a/backend/src/controllers/formController.js b/backend/src/controllers/formController.js new file mode 100644 index 0000000..b1fb547 --- /dev/null +++ b/backend/src/controllers/formController.js @@ -0,0 +1,52 @@ +const prisma = require('../config/db'); + +// @desc List submitted form responses with optional filters +// @route GET /api/forms/responses +// @access Private/Staff (admin, supervisor, staff) +const listFormResponses = async (req, res) => { + try { + const { eventId, userId, registrationId, limit, cursor } = req.query; + + // Build where clause + const where = {}; + if (registrationId) { + where.registrationId = String(registrationId); + } + // For eventId/userId we filter through the related registration + const registrationFilter = {}; + if (eventId) registrationFilter.eventId = String(eventId); + if (userId) registrationFilter.userId = String(userId); + if (Object.keys(registrationFilter).length > 0) { + // Prisma relation filter requires `is` wrapper + where.registration = { is: registrationFilter }; + } + + // Basic pagination (optional) + const take = Math.min(Math.max(parseInt(limit || '50', 10) || 50, 1), 200); + const cursorClause = cursor ? { id: String(cursor) } : undefined; + + const responses = await prisma.formResponse.findMany({ + where, + include: { + registration: { + include: { + user: { select: { id: true, name: true, email: true } }, + event: { select: { id: true, title: true } }, + } + }, + answers: { include: { field: true } }, + }, + orderBy: { createdAt: 'desc' }, + take, + ...(cursorClause ? { skip: 1, cursor: cursorClause } : {}), + }); + + const nextCursor = responses.length === take ? responses[responses.length - 1].id : null; + + res.json({ items: responses, nextCursor }); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +module.exports = { listFormResponses }; diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js new file mode 100644 index 0000000..804ba0e --- /dev/null +++ b/backend/src/controllers/paymentController.js @@ -0,0 +1,1205 @@ +const prisma = require('../config/db'); +const { v4: uuidv4 } = require('uuid'); +const { generateTicketsForRegistration } = require('../utils/ticketUtils'); +const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing'); +const axios = require('axios'); +const { emailTickets } = require('./ticketController'); +const { safeErrorMessage } = require('../utils/errorUtils'); +const { assertEventOpen } = require('../utils/cashupUtils'); + +// @desc Create a new payment +// @route POST /api/payments +// @access Private/Supervisor +const createPayment = async (req, res) => { + try { + const { amount, method, registrationId, eventId, isDonation, paidAt, userId: bodyUserId } = req.body; + + let userId = req.user.id; + + // Allow admins/supervisors to create payments for other users + if (bodyUserId) { + if (req.user.role !== 'admin' && req.user.role !== 'supervisor') { + res.status(403); + throw new Error('Not authorized to create payments for other users'); + } + + // Ensure target user exists + const targetUser = await prisma.user.findUnique({ + where: { id: bodyUserId } + }); + + if (!targetUser) { + res.status(404); + throw new Error('Target user not found'); + } + + userId = bodyUserId; + } + + // Optional backdate support for supervisors/admins + let paidAtDate = null; + if (paidAt) { + const d = new Date(paidAt); + if (!isNaN(d.getTime())) { + // Disallow future-dated payments; clamp to now + const now = new Date(); + paidAtDate = d > now ? now : d; + } + } + + // Validate payment data + if (!amount || amount <= 0) { + res.status(400); + throw new Error('Invalid payment amount'); + } + + if (!method) { + res.status(400); + throw new Error('Payment method is required'); + } + + // Validate required parameters based on isDonation flag + if (isDonation && !eventId) { + res.status(400); + throw new Error('Event ID is required for donations'); + } + + if (!isDonation && !registrationId) { + res.status(400); + throw new Error('Registration ID is required for non-donation payments'); + } + + let registrationEventId = null; + // Full registration object reused for both the auth check and the payment calculation below + let registration = null; + + // If it's a registration payment, fetch everything needed in one query (avoids a duplicate fetch later) + if (registrationId) { + registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true, + user: { select: { id: true } } + } + }); + + if (!registration) { + res.status(404); + throw new Error('Registration not found'); + } + + registrationEventId = registration.eventId; + + if (registration.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') { + res.status(403); + throw new Error('Not authorized to make payment for this registration'); + } + + await assertEventOpen(registrationEventId, res); + } + + // If it's an event donation, check if event exists + if (eventId && !registrationId) { + const event = await prisma.event.findUnique({ + where: { id: eventId } + }); + + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + + if (!event.isActive) { + res.status(400); + throw new Error('Cannot make donation to inactive event'); + } + + if (event.cashupStatus === 'closed') { + res.status(400); + throw new Error('This event is closed and no longer accepting donations.'); + } + } + + // Create payment(s) with support for overpayments becoming donations + let payment; + let donationSplit = null; + + // If this is a registration payment, we need to cap the applied amount and split any excess as a donation + if (!isDonation && registrationId) { + // Silently refresh early-bird prices so totalDue reflects any expired or exhausted tiers. + // For manual (supervisor) payments we don't block — we just ensure the totalDue is accurate. + try { await refreshPricingForRegistration(registrationId); } catch (e) { console.warn('Price refresh failed for manual payment:', e?.message); } + // Re-fetch after refresh so priceSnapshot values are current + registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true, + user: { select: { id: true } } + } + }); + + // Calculate remaining amount to be paid (at payment time) + const totalDue = computeRegistrationTotalDue(registration, paidAtDate || new Date()); + const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0); + const remainingAmount = Math.max(totalDue - totalPaid, 0); + + const requestedAmount = parseFloat(amount); + const applyAmount = Math.min(requestedAmount, remainingAmount); + const excess = Math.max(requestedAmount - applyAmount, 0); + + // If there's nothing remaining, treat the whole payment as a donation to the event + if (applyAmount <= 0) { + payment = await prisma.payment.create({ + data: { + id: uuidv4(), + amount: requestedAmount, + method, + userId, + registrationId: null, + eventId: registration.eventId, + isDonation: true, + createdAt: paidAtDate || undefined + }, + include: { + user: { select: { id: true, name: true, email: true } }, + event: true + } + }); + } else { + // Create the main registration payment for the capped amount + payment = await prisma.payment.create({ + data: { + id: uuidv4(), + amount: applyAmount, + method, + userId, + registrationId, + eventId: registrationEventId || eventId || null, + isDonation: false, + createdAt: paidAtDate || undefined + }, + include: { + user: { select: { id: true, name: true, email: true } }, + registration: { include: { event: true } }, + event: (registrationEventId || eventId) ? true : undefined + } + }); + + // If there is an excess, create a donation payment linked to the original payment + if (excess > 0) { + donationSplit = await prisma.payment.create({ + data: { + id: uuidv4(), + amount: excess, + method, + userId, + registrationId: null, + eventId: registration.eventId, + isDonation: true, + originalPaymentId: payment.id, + createdAt: paidAtDate || undefined + } + }); + } + } + } else { + // Original behavior for donations or non-registration payments + payment = await prisma.payment.create({ + data: { + id: uuidv4(), + amount: parseFloat(amount), + method, + userId, + registrationId: registrationId || null, + eventId: registrationEventId || eventId || null, + isDonation: isDonation || false, + createdAt: paidAtDate || undefined + }, + include: { + user: { select: { id: true, name: true, email: true } }, + registration: registrationId ? { include: { event: true } } : undefined, + event: (registrationEventId || eventId) ? true : undefined + } + }); + } + + // If it's a registration payment, update registration status + let updatedRegistration = null; + let generatedTickets = []; + + if (registrationId) { + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } } + } + }, + payments: true + } + }); + + // Calculate total amount paid + const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0); + + // Calculate total amount due at this time (respect early-bird tiers) + const totalDue = computeRegistrationTotalDue(registration, paidAtDate || new Date()); + + // Update registration status based on payment + let newStatus; + if (totalPaid >= totalDue) { + newStatus = 'paid'; + } else if (totalPaid > 0) { + newStatus = 'partial_paid'; + } else { + newStatus = 'pending'; + } + + updatedRegistration = await prisma.registration.update({ + where: { id: registrationId }, + data: { + status: newStatus, + updatedAt: new Date() + } + }); + + // Generate tickets when registration is fully paid (sync — populates generatedTickets for response) + if (newStatus === 'paid') { + try { + generatedTickets = await generateTicketsForRegistration(registrationId); + } catch (error) { + console.error('Error generating tickets:', error); + } + } + } + + // Fire-and-forget: payment email first, then tickets (guarantees order) + const { sendPaymentEmails } = require('../utils/notifications'); + const _cpPaymentId = payment?.id; + const _cpDonationId = donationSplit?.id; + const _cpShouldEmailTickets = generatedTickets.length > 0; + const _cpUserId = registration?.userId; + const _cpRegId = registrationId; + (async () => { + if (_cpPaymentId) { + try { await sendPaymentEmails(_cpPaymentId); } catch (e) { console.error('Failed to send payment emails:', e); } + } + if (_cpDonationId) { + try { await sendPaymentEmails(_cpDonationId); } catch (e) { console.error('Failed to send payment emails:', e); } + } + if (_cpShouldEmailTickets && _cpUserId) { + const mockReq = { user: { id: _cpUserId }, body: { registrationId: _cpRegId } }; + const mockRes = { status: () => mockRes, json: () => {} }; + try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets:', e); } + } + })(); + + // If registration was updated, include the updated registration in the response + if (updatedRegistration) { + // Create a new payment object with the updated registration + const paymentWithUpdatedRegistration = { + ...payment, + registration: { + ...payment.registration, + ...updatedRegistration + }, + generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined, + donationSplit: donationSplit || undefined + }; + res.status(201).json(paymentWithUpdatedRegistration); + } else { + res.status(201).json({ ...payment, donationSplit: donationSplit || undefined }); + } + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get all payments +// @route GET /api/payments +// @access Private/Admin +const getPayments = async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100)); + const skip = (page - 1) * limit; + + const include = { + user: { select: { id: true, name: true, email: true } }, + registration: { + include: { + event: true, + user: { select: { name: true, email: true, phoneNumber: true } } + } + }, + event: true + }; + + const [payments, total] = await prisma.$transaction([ + prisma.payment.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }), + prisma.payment.count() + ]); + + res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get user payments +// @route GET /api/payments/mypayments +// @access Private +const getUserPayments = async (req, res) => { + try { + // Users should see payments they made (donations) and payments tied to their registrations + const payments = await prisma.payment.findMany({ + where: { + OR: [ + { userId: req.user.id }, // payments made by the user (includes donations) + { registration: { userId: req.user.id } } // payments tied to the user's registrations + ] + }, + include: { + registration: { + include: { + event: true + } + }, + event: true + } + }); + + res.json(payments); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get payment by ID +// @route GET /api/payments/:id +// @access Private +const getPaymentById = async (req, res) => { + try { + const payment = await prisma.payment.findUnique({ + where: { id: req.params.id }, + include: { + user: { + select: { + id: true, + name: true, + email: true + } + }, + registration: { + include: { + event: true + } + }, + event: true + } + }); + + if (!payment) { + res.status(404); + throw new Error('Payment not found'); + } + + // Check if user is authorized to view this payment + if (payment.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { + res.status(403); + throw new Error('Not authorized to view this payment'); + } + + res.json(payment); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get payments by registration +// @route GET /api/payments/registration/:registrationId +// @access Private +const getPaymentsByRegistration = async (req, res) => { + try { + const registration = await prisma.registration.findUnique({ + where: { id: req.params.registrationId } + }); + + if (!registration) { + res.status(404); + throw new Error('Registration not found'); + } + + // Check if user is authorized to view these payments + if (registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { + res.status(403); + throw new Error('Not authorized to view these payments'); + } + + const payments = await prisma.payment.findMany({ + where: { registrationId: req.params.registrationId }, + include: { + user: { + select: { + id: true, + name: true, + email: true + } + } + } + }); + + res.json(payments); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get payments by event +// @route GET /api/payments/event/:eventId +// @access Private/Admin +const getPaymentsByEvent = async (req, res) => { + try { + const limit = Math.min(1000, Math.max(1, parseInt(req.query.limit) || 1000)); + + const payments = await prisma.payment.findMany({ + where: { + OR: [ + { eventId: req.params.eventId }, + { registration: { eventId: req.params.eventId } } + ] + }, + include: { + user: { select: { id: true, name: true, email: true } }, + registration: { + include: { user: { select: { id: true, name: true, email: true } } } + } + }, + orderBy: { createdAt: 'desc' }, + take: limit + }); + + res.json(payments); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Assign a donation to a registration +// @route PUT /api/payments/assign-donation +// @access Private/Supervisor +const assignDonationToRegistration = async (req, res) => { + try { + const { paymentId, registrationId, amount } = req.body; + + // Validate required parameters + if (!paymentId || !registrationId) { + res.status(400); + throw new Error('Payment ID and Registration ID are required'); + } + + // Find the payment + const payment = await prisma.payment.findUnique({ + where: { id: paymentId } + }); + + if (!payment) { + res.status(404); + throw new Error('Payment not found'); + } + + // Check if payment is a donation + if (!payment.isDonation) { + res.status(400); + throw new Error('Only donations can be assigned to registrations'); + } + + // Check if payment is already assigned to a registration + if (payment.registrationId) { + res.status(400); + throw new Error('This payment is already assigned to a registration'); + } + + if (payment.eventId) { + const donationEvent = await prisma.event.findUnique({ where: { id: payment.eventId }, select: { cashupStatus: true } }); + if (donationEvent && donationEvent.cashupStatus === 'closed') { + res.status(400); + throw new Error('Event is closed; donations can no longer be allocated'); + } + } + + // Find the registration + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } } + } + }, + payments: true + } + }); + + if (!registration) { + res.status(404); + throw new Error('Registration not found'); + } + + // The check above only covers the donation's own event — also block allocating into a + // registration whose event is closed, even if the donation itself came from an open one. + await assertEventOpen(registration.eventId, res); + + // Calculate total amount due for the registration + const totalDue = computeRegistrationTotalDue(registration, new Date()); + + // Calculate total amount already paid + const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0); + + // Calculate remaining amount to be paid + const remainingAmount = totalDue - totalPaid; + + // If registration is already paid in full + if (remainingAmount <= 0) { + res.status(400); + throw new Error('This registration is already paid in full'); + } + + // How much of the donation to apply — defaults to today's behaviour (as much as the + // donation covers, capped at what's owed) but staff can specify a smaller amount and + // deliberately leave the registrant owing a balance. + let allocateAmount = amount != null ? Number(amount) : Math.min(payment.amount, remainingAmount); + if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) { + res.status(400); + throw new Error('Allocation amount must be greater than zero'); + } + if (allocateAmount > payment.amount) { + res.status(400); + throw new Error('Cannot allocate more than the donation amount'); + } + if (allocateAmount > remainingAmount) { + res.status(400); + throw new Error(`Cannot allocate more than the outstanding balance of R${remainingAmount.toFixed(2)}`); + } + + let updatedRegistration; + let generatedTickets = []; + let originalPaymentId = payment.id; + let splitPayment = null; + + // Update the payment to be associated with the registration and adjust amount + await prisma.payment.update({ + where: { id: payment.id }, + data: { + registrationId, + amount: allocateAmount, + isDonation: false + } + }); + + // If less than the full donation was allocated, the remainder stays as an unassigned + // donation (same donor, no notification — it's a bookkeeping split, not a new gift). + if (allocateAmount < payment.amount) { + const leftoverAmount = payment.amount - allocateAmount; + + splitPayment = await prisma.payment.create({ + data: { + id: uuidv4(), + amount: leftoverAmount, + method: payment.method, + userId: payment.userId, + eventId: payment.eventId, + isDonation: true, + externalId: payment.externalId ? `${payment.externalId}-split` : null, + status: payment.status, + originalPaymentId: payment.id, + createdAt: payment.createdAt + } + }); + } + + if (allocateAmount >= remainingAmount) { + // Fully covers what's owed + updatedRegistration = await prisma.registration.update({ + where: { id: registrationId }, + data: { + status: 'paid', + updatedAt: new Date() + } + }); + + // Generate tickets (sync — populates generatedTickets for response) + try { + generatedTickets = await generateTicketsForRegistration(registrationId); + } catch (error) { + console.error('Error generating tickets:', error); + } + } else { + updatedRegistration = await prisma.registration.update({ + where: { id: registrationId }, + data: { + status: 'partial_paid', + updatedAt: new Date() + } + }); + } + + // Fire-and-forget: notify the registrant (not the donor — see sendDonationAssignmentEmails), + // then tickets (guarantees order). The split/leftover payment is never notified. + const { sendDonationAssignmentEmails } = require('../utils/notifications'); + const _adPaymentId = payment?.id; + const _adShouldEmailTickets = generatedTickets.length > 0; + const _adUserId = registration?.userId; + const _adRegId = registrationId; + (async () => { + if (_adPaymentId) { + try { await sendDonationAssignmentEmails(_adPaymentId); } catch (e) { console.error('Failed to send emails after assigning donation:', e); } + } + if (_adShouldEmailTickets && _adUserId) { + const mockReq = { user: { id: _adUserId }, body: { registrationId: _adRegId } }; + const mockRes = { status: () => mockRes, json: () => {} }; + try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after assigning donation:', e); } + } + })(); + + // Prepare response + const result = { + message: 'Donation successfully assigned to registration', + originalPaymentId, + updatedRegistration, + generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined, + splitPayment: splitPayment ? { + ...splitPayment, + originalPaymentId: payment.id + } : null + }; + + res.status(200).json(result); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount } +// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration. +async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) { + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } }, + variant: true, + } + }, + payments: true, + user: { select: { id: true, name: true, email: true } }, + event: true + } + }); + + if (!registration) throw new Error('Registration not found'); + + const totalDue = computeRegistrationTotalDue(registration, new Date()); + const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0); + const remainingAmount = totalDue - totalPaid; + + if (remainingAmount <= 0) throw new Error('This registration is already paid in full'); + + const amountInCents = Math.round(remainingAmount * 100); + const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`; + const now = new Date(); + + const lastPaymentAt = registration.payments.length > 0 + ? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime()))) + : null; + + const lineItems = registration.registrationOptions.map(option => { + const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : ''; + // Use priceSnapshot when available (variant-aware, authoritative); fall back to deadline-only check + const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined) + ? option.priceSnapshot + : require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now); + return { + displayName: `${option.eventOption.name}${variantLabel}`, + quantity: option.quantity, + pricingDetails: { + price: Math.round(unitPrice * 100) + } + }; + }); + + // Show prior payments as a deduction line + if (totalPaid > 0) { + lineItems.push({ + displayName: 'Previous Payments', + quantity: 1, + pricingDetails: { price: -Math.round(totalPaid * 100) } + }); + } + // Paying full remaining — no "Partial Payment" line needed + + const checkoutData = { + amount: amountInCents, + currency: 'ZAR', + metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, + successUrl, + cancelUrl, + failureUrl, + lineItems + }; + + const response = await axios.post( + 'https://payments.yoco.com/api/checkouts', + checkoutData, + { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } } + ); + + await prisma.registration.update({ + where: { id: registrationId }, + data: { checkoutId: response.data.id, updatedAt: new Date() } + }); + + return { + checkoutId: response.data.id, + redirectUrl: response.data.redirectUrl, + amount: remainingAmount, + registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } + }; +} + +// @desc Create a Yoco checkout for a registration +// @route POST /api/payments/yoco-checkout +// @access Private +const createYocoCheckout = async (req, res) => { + try { + const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = req.body; + const userId = req.user.id; + + // Validate required parameters for either registration payment or donation + if (!registrationId && !eventId) { + res.status(400); + throw new Error('Either registrationId or eventId is required'); + } + + // Branch 1: Registration payment — delegate to internal helper + if (registrationId) { + // Authorisation check (the internal helper skips this) + const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true } }); + if (!reg) { res.status(404); throw new Error('Registration not found'); } + if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') { + res.status(403); throw new Error('Not authorized to create checkout for this registration'); + } + await assertEventOpen(reg.eventId, res); + + // Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out. + // Must run before totalDue is computed so the correct price is used for the checkout amount. + const { changed: priceChanged } = await refreshPricingForRegistration(registrationId); + if (priceChanged) { + // Re-fetch with fresh priceSnapshot values + const freshReg = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true + } + }); + const newTotal = computeRegistrationTotalDue(freshReg, new Date()); + const totalPaid = (freshReg.payments || []).reduce((s, p) => s + p.amount, 0); + return res.status(200).json({ + priceUpdated: true, + newTotal: Math.max(0, newTotal - totalPaid), + message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' + }); + } + + // If a specific partial amount was passed, fall through to the old inline path + if (amount && !isNaN(parseFloat(amount))) { + // Re-use old inline logic for partial payments + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } }, + variant: true, + } + }, + payments: true, + user: { select: { id: true, name: true, email: true } }, + event: true + } + }); + const totalDue = computeRegistrationTotalDue(registration, new Date()); + const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0); + const remainingAmount = totalDue - totalPaid; + if (remainingAmount <= 0) { res.status(400); throw new Error('This registration is already paid in full'); } + const requested = parseFloat(amount); + if (requested <= 0) { res.status(400); throw new Error('Amount must be greater than 0'); } + if (remainingAmount >= 15 && requested < 15) { res.status(400); throw new Error('Minimum payment is R15'); } + const charge = Math.min(requested, remainingAmount); + const amountInCents = Math.round(charge * 100); + const now = new Date(); + const lastPaymentAt = registration.payments.length > 0 + ? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime()))) + : null; + const lineItems = registration.registrationOptions.map(option => { + const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : ''; + const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined) + ? option.priceSnapshot + : require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now); + return { + displayName: `${option.eventOption.name}${variantLabel}`, + quantity: option.quantity, + pricingDetails: { price: Math.round(unitPrice * 100) } + }; + }); + // Previous payments deduction + if (totalPaid > 0) { + lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } }); + } + // Partial payment deduction — amount being deferred to a future payment + if (charge < remainingAmount) { + lineItems.push({ displayName: 'Partial Payment', quantity: 1, pricingDetails: { price: -Math.round((remainingAmount - charge) * 100) } }); + } + const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems }; + const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } }); + await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } }); + return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } }); + } + + const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl }); + return res.status(200).json(result); + } + + // Branch 2: Donation to an event + if (eventId) { + // Validate event + const event = await prisma.event.findUnique({ where: { id: eventId } }); + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + if (!event.isActive) { + res.status(400); + throw new Error('Cannot make donation to inactive event'); + } + if (event.cashupStatus === 'closed') { + res.status(400); + throw new Error('This event is closed and no longer accepting donations.'); + } + + const amt = parseFloat(amount); + if (!(amt > 0)) { + res.status(400); + throw new Error('Valid amount is required for donation'); + } + if (amt < 15) { + res.status(400); + throw new Error('Minimum donation is R15'); + } + + const amountInCents = Math.round(amt * 100); + const idempotencyKey = `don_${eventId}_${amountInCents}_${Date.now()}`; + const metadata = { + userId, + eventId, + eventTitle: event.title, + type: 'donation', + isDonation: true + }; + + const checkoutData = { + amount: amountInCents, + currency: 'ZAR', + metadata, + successUrl, + cancelUrl, + failureUrl, + lineItems: [ + { displayName: `Donation to ${event.title}`, quantity: 1, pricingDetails: { price: amountInCents } } + ] + }; + + const response = await axios.post( + 'https://payments.yoco.com/api/checkouts', + checkoutData, + { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } } + ); + + return res.status(200).json({ + checkoutId: response.data.id, + redirectUrl: response.data.redirectUrl, + amount: amt, + event: { id: event.id, title: event.title } + }); + } + } catch (error) { + console.error('Yoco checkout error:', error.response?.data || error.message); + res.status(error.response?.status || 400).json({ + message: error.response?.data?.message || error.message + }); + } +}; + +// @desc Deliver a previously-generated Yoco payment link to the registrant via email or WhatsApp +// @route POST /api/payments/yoco-checkout/send +// @access Private/Supervisor +const sendPaymentLink = async (req, res) => { + try { + const { registrationId, redirectUrl, channel } = req.body; + + if (!registrationId || !redirectUrl || !['email', 'whatsapp'].includes(channel)) { + res.status(400); + throw new Error('registrationId, redirectUrl and a valid channel are required'); + } + + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { user: true, event: true } + }); + if (!registration) { res.status(404); throw new Error('Registration not found'); } + + const { user, event } = registration; + const message = `Hi ${user.name || ''}, here's your payment link for ${event?.title || 'your registration'}: ${redirectUrl}`; + + if (channel === 'email') { + if (!user.email) { res.status(400); throw new Error('This user has no email address on file'); } + const { sendMail } = require('../utils/email'); + await sendMail({ + to: user.email, + subject: `Payment link — ${event?.title || 'Registration'}`, + text: message, + html: `

Hi ${user.name || ''},

Here's your payment link for ${event?.title || 'your registration'}:

${redirectUrl}

` + }); + } else { + const { isValidZAPhone } = require('../utils/whatsapp'); + if (!isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('This user has no valid WhatsApp number on file'); } + const { waTextAny } = require('../utils/notify'); + await waTextAny(user, message); + } + + return res.status(200).json({ sent: true, channel }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Create a refund (records as a negative payment) +// @route POST /api/payments/refund +// @access Private/Supervisor +const createRefund = async (req, res) => { + try { + const { userId, amount, method, paymentId, registrationId, reason } = req.body; + + // Validate role via middleware, but double-check + if (req.user.role !== 'admin' && req.user.role !== 'supervisor') { + res.status(403); + throw new Error('Not authorized to create refunds'); + } + + // Input validation + const amt = parseFloat(amount); + if (!(amt > 0)) { + res.status(400); + throw new Error('Refund amount must be greater than 0'); + } + if (!userId) { + res.status(400); + throw new Error('User is required for refund'); + } + if (!paymentId && !registrationId) { + res.status(400); + throw new Error('Either paymentId or registrationId must be provided'); + } + + let linkRegistrationId = null; + let linkEventId = null; + let originalPayment = null; + + // If refunding a specific payment, fetch it and infer links + if (paymentId) { + originalPayment = await prisma.payment.findUnique({ where: { id: paymentId } }); + if (!originalPayment) { + res.status(404); + throw new Error('Original payment not found'); + } + // Optional: Ensure same user unless explicit + linkRegistrationId = originalPayment.registrationId || null; + linkEventId = originalPayment.eventId || null; + } + + // If registrationId directly provided, ensure it exists + if (!linkRegistrationId && registrationId) { + const reg = await prisma.registration.findUnique({ where: { id: registrationId } }); + if (!reg) { + res.status(404); + throw new Error('Registration not found'); + } + linkRegistrationId = reg.id; + linkEventId = reg.eventId; + } + + if (linkEventId) { + await assertEventOpen(linkEventId, res); + } + + // Pre-check: If linked to a registration, determine if this refund would downgrade from paid and handle tickets + let willDowngradeFromPaid = false; + let originalStatus = null; + if (linkRegistrationId) { + const registration = await prisma.registration.findUnique({ + where: { id: linkRegistrationId }, + include: { + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true, + // Load tickets to check usage if needed + _count: true + } + }); + if (registration) { + originalStatus = registration.status; + const totalPaidExisting = registration.payments.reduce((sum, p) => sum + p.amount, 0); + const totalDue = computeRegistrationTotalDue(registration, new Date()); + const totalPaidAfter = totalPaidExisting - Math.abs(amt); + willDowngradeFromPaid = (originalStatus === 'paid') && (totalPaidAfter < totalDue); + if (willDowngradeFromPaid) { + // Check for used tickets; if any are used, block the refund + const regTickets = await prisma.ticket.findMany({ + where: { registrationOption: { registrationId: linkRegistrationId } }, + include: { usages: true } + }); + const hasUsed = regTickets.some(t => t.isUsed || (t.usages && t.usages.length > 0)); + if (hasUsed) { + res.status(400); + throw new Error('Cannot process refund because the ticket has already been used'); + } + } + } + } + + // Create the negative payment row + const negativePayment = await prisma.payment.create({ + data: { + id: uuidv4(), + amount: -Math.abs(amt), + method: method || 'refund', + userId, + registrationId: linkRegistrationId, + eventId: linkEventId, + isDonation: false, + originalPaymentId: originalPayment ? originalPayment.id : null, + status: reason ? String(reason).slice(0, 255) : null + }, + include: { + user: { select: { id: true, name: true, email: true } }, + registration: { include: { event: true } }, + event: true + } + }); + + // If tied to a registration, recalc and update status + if (linkRegistrationId) { + const registration = await prisma.registration.findUnique({ + where: { id: linkRegistrationId }, + include: { + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true + } + }); + if (registration) { + const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0); + const totalDue = computeRegistrationTotalDue(registration, new Date()); + let newStatus; + if (totalPaid >= totalDue) newStatus = 'paid'; + else if (totalPaid > 0) newStatus = 'partial_paid'; + else newStatus = 'pending'; + await prisma.registration.update({ where: { id: linkRegistrationId }, data: { status: newStatus, updatedAt: new Date() } }); + + // If we downgraded from paid to not-paid, delete any unused tickets + if (originalStatus === 'paid' && newStatus !== 'paid') { + await prisma.ticket.deleteMany({ + where: { + registrationOption: { registrationId: linkRegistrationId }, + isUsed: false + } + }); + } + } + } + + // Fire-and-forget refund notification email + const { sendRefundEmail } = require('../utils/notifications'); + sendRefundEmail(negativePayment.id).catch(e => console.error('Failed to send refund email:', e)); + + return res.status(201).json(negativePayment); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +const getPaymentStats = async (req, res) => { + try{ + const startOfDay = new Date(new Date().setHours(0, 0, 0, 0)); + const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000); + const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000); + + const [totalToday, totalWeek, totalMonth] = await Promise.all([ + prisma.payment.aggregate({ + _sum: { + amount: true + }, + where: { + createdAt: { + gte: startOfDay + } + } + }), + prisma.payment.aggregate({ + _sum: { + amount: true + }, + where: { + createdAt: { + gte: lastWeek + } + } + }), + prisma.payment.aggregate({ + _sum: { + amount: true + }, + where: { + createdAt: { + gte: lastMonth + } + } + }) + ]); + res.status(200).json({ + totalToday: totalToday._sum.amount || 0, + totalWeek: totalWeek._sum.amount || 0, + totalMonth: totalMonth._sum.amount || 0, + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +} + +module.exports = { + createPayment, + getPayments, + getUserPayments, + getPaymentById, + getPaymentsByRegistration, + getPaymentsByEvent, + assignDonationToRegistration, + createYocoCheckout, + createRegistrationCheckoutInternal, + sendPaymentLink, + createRefund, + getPaymentStats +}; \ No newline at end of file diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js new file mode 100644 index 0000000..6a9ebcf --- /dev/null +++ b/backend/src/controllers/registrationController.js @@ -0,0 +1,1510 @@ +const prisma = require('../config/db'); +const { v4: uuidv4 } = require('uuid'); +const axios = require("axios"); +const { generateTicketsForRegistration } = require('../utils/ticketUtils'); +const { emailTickets } = require('./ticketController'); +const { hashPassword } = require('../config/auth'); +const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue } = require('../utils/pricing'); +const { assertEventOpen } = require('../utils/cashupUtils'); + +/** + * Check overall stock availability for an EventOption. + * Returns { available: boolean, remaining: number|null } + */ +async function checkOptionStock(option, requestedQty) { + if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null }; + + const soldAgg = await prisma.registrationOption.aggregate({ + where: { eventOptionId: option.id, registration: { status: { not: 'cancelled' } } }, + _sum: { quantity: true } + }); + const sold = soldAgg._sum?.quantity || 0; + const remaining = option.stockLimit - sold; + return { available: remaining >= requestedQty, remaining }; +} + +/** + * Check stock availability for a specific variant. + */ +async function checkVariantStock(variant, requestedQty) { + if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null }; + + const soldAgg = await prisma.registrationOption.aggregate({ + where: { variantId: variant.id, registration: { status: { not: 'cancelled' } } }, + _sum: { quantity: true } + }); + const sold = soldAgg._sum?.quantity || 0; + const remaining = variant.stockLimit - sold; + return { available: remaining >= requestedQty, remaining }; +} + +// @desc Create a new registration +// @route POST /api/registrations +// @access Private +const createRegistration = async (req, res) => { + try { + const { eventId, options, guestName, guestEmail, guestPhone } = req.body; + + // Check if event exists + const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function'); + const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); + const optionInclude = canIncludeTiers + ? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: true } : {}) } + : true; + const event = await prisma.event.findUnique({ + where: { id: eventId }, + include: { eventOptions: optionInclude ? { include: optionInclude } : true } + }); + + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + + if (!event.isActive) { + res.status(400); + throw new Error('Event is not active'); + } + + if (event.cashupStatus === 'closed') { + res.status(400); + throw new Error('This event is closed and no longer accepting registrations.'); + } + + // Enforce auth unless event explicitly opts out + const eventRequiresAuth = event.requiresAuth !== false; + if (eventRequiresAuth && !req.user) { + res.status(401); + throw new Error('Authentication required for this event'); + } + + // Unauthenticated guest registration is only permitted for free events + if (!req.user) { + const totalDueCheck = (event.eventOptions || []).reduce((sum, eo) => sum + (eo.price || 0), 0); + // Also check against the requested options specifically + const requestedTotal = (options || []).reduce((sum, opt) => { + const eo = event.eventOptions.find(o => o.id === opt.eventOptionId); + return sum + ((eo?.price || 0) * (opt.quantity || 1)); + }, 0); + if (requestedTotal > 0) { + res.status(400); + throw new Error('Guest registration is only available for free events. Please create an account to pay for tickets.'); + } + } + + // Resolve userId — authenticated or guest + let userId; + if (req.user) { + userId = req.user.id; + } else { + // Guest registration: find existing user by email or create inactive placeholder + if (!guestEmail || !guestName) { + res.status(400); + throw new Error('Name and email are required for unauthenticated registration'); + } + const existingGuest = await prisma.user.findUnique({ where: { email: guestEmail } }); + if (existingGuest) { + userId = existingGuest.id; + } else { + const newGuest = await prisma.user.create({ + data: { + id: uuidv4(), + name: guestName, + email: guestEmail, + password: '', + phoneNumber: guestPhone || null, + isActive: false, + updatedAt: new Date(), + } + }); + userId = newGuest.id; + } + } + + // Enforce registration cutoff for public registrations + const now = new Date(); + const end = new Date(event.endDate); + const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; + if ((deadline && now >= deadline) || now >= end) { + res.status(400); + throw new Error('Registration is closed for this event'); + } + + // Validate options + if (!options || !Array.isArray(options) || options.length === 0) { + res.status(400); + throw new Error('At least one option must be selected'); + } + + // Validate each option and resolve prices / check stock + const resolvedOptions = []; + for (const option of options) { + const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId); + if (!eventOption) { + res.status(400); + throw new Error(`Option with ID ${option.eventOptionId} not found for this event`); + } + if (!option.quantity || option.quantity < 1) { + res.status(400); + throw new Error('Quantity must be at least 1'); + } + + const qty = option.quantity; + + // Check overall option stock + try { + const stockCheck = await checkOptionStock(eventOption, qty); + if (!stockCheck.available) { + res.status(400); + throw new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`); + } + } catch (e) { + if (res.statusCode !== 200) throw e; // propagate stock errors + // If stock check function fails (pre-migration), continue without stock check + } + + // Check variant stock (if a variant is selected) + let variantId = option.variantId || null; + let variantPrice = null; + if (variantId && canIncludeVariants) { + const variant = (eventOption.variants || []).find(v => v.id === variantId); + if (!variant) { + res.status(400); + throw new Error(`Variant not found for option "${eventOption.name}"`); + } + try { + const vStock = await checkVariantStock(variant, qty); + if (!vStock.available) { + res.status(400); + throw new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`); + } + } catch (e) { + if (res.statusCode !== 200) throw e; + } + variantPrice = variant.price; // null = use option price + } + + // Resolve price: variant-level tiers take priority, then option-level tiers, then base prices + let priceSnapshot = null; + let appliedTierId = null; + if (canIncludeTiers) { + try { + if (variantId) { + // Try variant-specific early-bird tier first + const variantResolved = await resolveVariantTierPrice(eventOption, variantId, qty); + if (variantResolved.tierId) { + priceSnapshot = variantResolved.price; + appliedTierId = variantResolved.tierId; + } else { + // No variant tier — use the variant's own price (may be null → falls back to option price below) + priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null; + } + } else { + // No variant — use option-level early-bird tier + const resolved = await resolveOptionPrice(eventOption, qty); + priceSnapshot = resolved.price; + appliedTierId = resolved.tierId; + } + } catch (e) { + priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : eventOption.price; + } + } else { + priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null; + } + if (priceSnapshot === null) priceSnapshot = eventOption.price; + + resolvedOptions.push({ ...option, variantId, appliedTierId, priceSnapshot }); + } + + // Check for existing non-cancelled registration for this user+event → merge instead + const existingReg = await prisma.registration.findFirst({ + where: { userId, eventId, status: { not: 'cancelled' } }, + include: { registrationOptions: true } + }); + + let registration; + let isNewRegistration = false; + if (existingReg) { + // Merge: upsert each requested option into the existing registration + for (const opt of resolvedOptions) { + // Match on eventOptionId + variantId for correct row + const existing = existingReg.registrationOptions.find( + ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) + ); + if (existing) { + await prisma.registrationOption.update({ + where: { id: existing.id }, + data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null } + }); + } else { + await prisma.registrationOption.create({ + data: { + id: uuidv4(), + registrationId: existingReg.id, + eventOptionId: opt.eventOptionId, + quantity: opt.quantity, + variantId: opt.variantId || null, + appliedTierId: opt.appliedTierId || null, + priceSnapshot: opt.priceSnapshot, + } + }); + } + } + // Only downgrade from paid if the newly added items actually cost something + const newItemsCost = resolvedOptions.reduce((sum, opt) => { + return sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0)); + }, 0); + if (existingReg.status === 'paid' && newItemsCost > 0) { + await prisma.registration.update({ where: { id: existingReg.id }, data: { status: 'partial_paid', updatedAt: new Date() } }); + } else { + await prisma.registration.update({ where: { id: existingReg.id }, data: { updatedAt: new Date() } }); + } + registration = await prisma.registration.findUnique({ + where: { id: existingReg.id }, + include: { + registrationOptions: { include: { eventOption: true } }, + event: true, + user: { select: { id: true, name: true, email: true, phoneNumber: true } } + } + }); + } else { + isNewRegistration = true; + // Create new registration + const registrationId = uuidv4(); + registration = await prisma.registration.create({ + data: { + id: registrationId, + userId, + eventId, + updatedAt: new Date(), + registrationOptions: { + create: resolvedOptions.map(option => ({ + id: uuidv4(), + eventOptionId: option.eventOptionId, + quantity: option.quantity, + variantId: option.variantId || null, + appliedTierId: option.appliedTierId || null, + priceSnapshot: option.priceSnapshot, + })) + } + }, + include: { + registrationOptions: { include: { eventOption: true } }, + event: true, + user: { select: { id: true, name: true, email: true, phoneNumber: true } } + } + }); + } + + // If total due is zero, mark as paid and generate/update tickets if no required form + let freeTicketMockReq = null, freeTicketMockRes = null; + try { + const totalDue = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0); + if (totalDue === 0) { + // Mark paid (no-op if already paid) + if (registration.status !== 'paid') { + await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } }); + } + // check if event has a required form + let hasRequiredForm = false; + try { + const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }); + hasRequiredForm = !!(form && form.isRequired); + } catch {} + if (!hasRequiredForm) { + // Batch-fetch existing tickets then create/update in parallel (1 read + N parallel writes vs N×2 sequential) + const optionIds = registration.registrationOptions.map(o => o.id); + const existingTickets = await prisma.ticket.findMany({ where: { registrationOptionId: { in: optionIds } } }); + const ticketByOptionId = Object.fromEntries(existingTickets.map(t => [t.registrationOptionId, t])); + await Promise.all(registration.registrationOptions.map(option => { + const exists = ticketByOptionId[option.id]; + if (exists) { + const newQty = option.quantity || 1; + if (newQty > (exists.quantity || 1)) { + return prisma.ticket.update({ where: { id: exists.id }, data: { quantity: newQty, updatedAt: new Date() } }); + } + return Promise.resolve(); + } + return prisma.ticket.create({ + data: { + id: uuidv4(), + qrCode: uuidv4(), + registrationOptionId: option.id, + userId: registration.userId, + eventId: registration.eventId, + quantity: option.quantity || 1, + updatedAt: new Date(), + } + }); + })); + // Capture for chained email — tickets sent after registration confirmation + freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } }; + freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} }; + } + + // reflect status change in the returned object + registration.status = 'paid'; + } + } catch (e) { + // ignore errors in auto-generation path; manual generation remains available + } + + // Fire-and-forget: registration email first, then tickets (guarantees order) + const { sendRegistrationEmails, sendRegistrationUpdatedEmails } = require('../utils/notifications'); + const _regId = registration.id; + const _freeTicketReq = freeTicketMockReq; + const _freeTicketRes = freeTicketMockRes; + (async () => { + try { + if (isNewRegistration) { + await sendRegistrationEmails(_regId); + } else { + await sendRegistrationUpdatedEmails(_regId); + } + } catch (e) { console.error('Failed to send registration emails:', e); } + if (_freeTicketReq) { + try { await emailTickets(_freeTicketReq, _freeTicketRes); } catch (e) { console.error('Error emailing tickets for free registration:', e); } + } + })(); + + res.status(201).json(registration); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Get all registrations +// @route GET /api/registrations +// @access Private/Admin +const getRegistrations = async (req, res) => { + try { + const registrations = await prisma.registration.findMany({ + include: { + payments: true, + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } }, + variant: { select: { id: true, name: true, price: true } }, + } + }, + event: true, + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true + } + } + } + }); + + res.json(registrations); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Get user registrations +// @route GET /api/registrations/myregistrations +// @access Private +const getUserRegistrations = async (req, res) => { + try { + const showPast = String(req.query.showPast || '').toLowerCase() === '1' || String(req.query.showPast || '').toLowerCase() === 'true'; + const now = new Date(); + const whereClause = showPast ? { userId: req.user.id } : { + userId: req.user.id, + status: { not: 'cancelled' }, + event: { endDate: { gte: now } } + }; + + const registrations = await prisma.registration.findMany({ + where: whereClause, + include: { + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } }, + variant: { select: { id: true, name: true, price: true } }, + } + }, + // Nest the event's form so the frontend can tell whether attendee forms are + // required without a separate GET /api/events/:id per registration. + event: { include: { form: { include: { fields: true } } } }, + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true + } + } + }, + orderBy: { createdAt: 'desc' } + }); + + res.json(registrations); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Get registration by ID +// @route GET /api/registrations/:id +// @access Private +const getRegistrationById = async (req, res) => { + try { + const registration = await prisma.registration.findUnique({ + where: { id: req.params.id }, + include: { + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } }, + variant: { select: { id: true, name: true, price: true } }, + tickets: true + } + }, + event: true, + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true + } + }, + payments: true, + formResponses: { include: { answers: true } } + } + }); + + if (!registration) { + res.status(404); + throw new Error('Registration not found'); + } + + // Guests (no auth) can view by knowing the registrationId (UUID = unguessable) + // Authenticated users must be the owner or staff+ + if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { + res.status(403); + throw new Error('Not authorized to view this registration'); + } + + res.json(registration); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Update registration status +// @route PUT /api/registrations/:id +// @access Private/Admin +const updateRegistrationStatus = async (req, res) => { + try { + const { status } = req.body; + + const registration = await prisma.registration.findUnique({ + where: { id: req.params.id } + }); + + if (!registration) { + res.status(404); + throw new Error('Registration not found'); + } + + await assertEventOpen(registration.eventId, res); + + // If downgrading from paid to not-paid, ensure no tickets were used and delete unused tickets + if (registration.status === 'paid' && status !== 'paid') { + const tickets = await prisma.ticket.findMany({ + where: { registrationOption: { registrationId: req.params.id } }, + include: { usages: true } + }); + const hasUsed = tickets.some(t => t.isUsed || (t.usages && t.usages.length > 0)); + if (hasUsed) { + res.status(400); + throw new Error('Cannot change registration from paid because one or more tickets have been used'); + } + // Delete all unused tickets + await prisma.ticket.deleteMany({ + where: { + registrationOption: { registrationId: req.params.id }, + isUsed: false + } + }); + } + + const updatedRegistration = await prisma.registration.update({ + where: { id: req.params.id }, + data: { + status, + updatedAt: new Date() + }, + include: { + registrationOptions: { + include: { + eventOption: true + } + }, + event: true, + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true + } + } + } + }); + + // Generate tickets and email them when status is manually set to 'paid' by staff + if (status === 'paid') { + (async () => { + try { + await generateTicketsForRegistration(req.params.id); + const mockReq = { user: { id: updatedRegistration.userId }, body: { registrationId: req.params.id } }; + const mockRes = { status: () => mockRes, json: () => {} }; + await emailTickets(mockReq, mockRes); + } catch (e) { + console.error('[updateRegistrationStatus] Failed to generate/email tickets:', e?.message || e); + } + })(); + } + + res.json(updatedRegistration); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Cancel registration +// @route DELETE /api/registrations/:id +// @access Private +const cancelRegistration = async (req, res) => { + try { + const registration = await prisma.registration.findUnique({ + where: { id: req.params.id } + }); + + if (!registration) { + res.status(404); + throw new Error('Registration not found'); + } + + // Check if user is authorized to cancel this registration + if (registration.userId !== req.user.id && req.user.role !== 'admin') { + res.status(403); + throw new Error('Not authorized to cancel this registration'); + } + + await assertEventOpen(registration.eventId, res); + + // Check if registration can be cancelled + if (registration.status === 'cancelled') { + res.status(400); + throw new Error('Registration is already cancelled'); + } + + // Non-admins cannot cancel a registration that has payments against it + if (req.user.role !== 'admin') { + const paymentCount = await prisma.payment.count({ + where: { registrationId: registration.id, amount: { gt: 0 } } + }); + if (paymentCount > 0) { + res.status(403); + throw new Error('This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.'); + } + } + + const updatedRegistration = await prisma.registration.update({ + where: { id: req.params.id }, + data: { + status: 'cancelled', + updatedAt: new Date() + } + }); + + res.json({ message: 'Registration cancelled', registration: updatedRegistration }); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Get registrations by event +// @route GET /api/registrations/event/:eventId +// @access Private/Admin +const getRegistrationsByEvent = async (req, res) => { + try { + const { search } = req.query; + let registrations = await prisma.registration.findMany({ + where: { eventId: req.params.eventId }, + include: { + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } }, + variant: { select: { id: true, name: true, price: true } }, + tickets: true, + } + }, + payments: true, + user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } } + }, + orderBy: { createdAt: 'asc' } + }); + + if (search) { + const q = String(search).toLowerCase(); + registrations = registrations.filter(r => + String(r.user?.name || '').toLowerCase().includes(q) || + String(r.user?.email || '').toLowerCase().includes(q) || + String(r.user?.phoneNumber || '').toLowerCase().includes(q) + ); + } + + res.json(registrations); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Create a new registration for non existing user +// @route POST /api/registrations/manual +// @access Private/Supervisor +const createManualRegistration = async (req, res) => { + let userRecord; + try { + const { eventId, options, user, guestOnly, notificationPreference: prefFromBody } = req.body; + + if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) { + res.status(400); + throw new Error('Missing required fields: eventId, options, user.name, and at least user.email or user.phoneNumber'); + } + + //Check event + const event = await prisma.event.findUnique({ + where: {id: eventId}, + include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } }, + }); + + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + + if (!event.isActive) { + res.status(400); + throw new Error('Event is not active'); + } + + await assertEventOpen(eventId, res); + + // Validate options + if (!Array.isArray(options) || options.length === 0) { + res.status(400); + throw new Error('At least one option must be selected'); + } + + // Resolve pricing for each option (appliedTierId + priceSnapshot) + const resolvedManualOptions = []; + for (const option of options) { + const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId); + if (!eventOption) { + res.status(400); + throw new Error(`Option with ID ${option.eventOptionId} not found for this event`); + } + if (!option.quantity || option.quantity < 1) { + res.status(400); + throw new Error('Quantity must be at least 1'); + } + + const variantId = option.variantId || null; + let priceSnapshot = null; + let appliedTierId = null; + try { + if (variantId) { + const variantResolved = await resolveVariantTierPrice(eventOption, variantId, option.quantity); + if (variantResolved.tierId) { + priceSnapshot = variantResolved.price; + appliedTierId = variantResolved.tierId; + } else { + const variant = (eventOption.variants || []).find(v => v.id === variantId); + priceSnapshot = (variant && variant.price !== null && variant.price !== undefined) + ? Number(variant.price) + : Number(eventOption.price || 0); + } + } else { + const resolved = await resolveOptionPrice(eventOption, option.quantity); + priceSnapshot = resolved.price; + appliedTierId = resolved.tierId; + } + } catch (e) { + priceSnapshot = Number(eventOption.price || 0); + } + + resolvedManualOptions.push({ ...option, variantId, appliedTierId, priceSnapshot }); + } + + // Resolve or create a user + let userId; + + const { normalizeZAPhone } = require('../utils/whatsapp'); + const phone = normalizeZAPhone(user.phoneNumber) || (user.phoneNumber || '').replace(/\D/g, ''); + const hasValidEmail = !!(user.email && typeof user.email === 'string' && user.email.includes('@')); + + // Derive notification preference: explicit choice wins; otherwise infer from available contact info + const validPrefs = ['email', 'whatsapp', 'both']; + const derivedPref = (prefFromBody && validPrefs.includes(prefFromBody)) + ? prefFromBody + : (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email'); + + // Always search by email AND/OR phone regardless of guestOnly + // Also try the alternate format (27xxx ↔ 0xxx) so both representations match + const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null); + const searchClauses = [ + ...(hasValidEmail ? [{ email: user.email }] : []), + ...(phone ? [{ phoneNumber: phone }] : []), + ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []), + ]; + const existingUser = searchClauses.length > 0 + ? await prisma.user.findFirst({ where: { OR: searchClauses } }) + : null; + + if (existingUser) { + userId = existingUser.id; + const updateData = {}; + // Update preference only when the caller explicitly specified one + if (prefFromBody && validPrefs.includes(prefFromBody)) { + updateData.notificationPreference = prefFromBody; + } + // Fill in a missing contact channel with the newly supplied value, without overwriting an existing one + if (phone && !existingUser.phoneNumber) { + updateData.phoneNumber = phone; + } + if (hasValidEmail && existingUser.email !== user.email && existingUser.email.endsWith('@guest.local')) { + updateData.email = user.email; + } + if (Object.keys(updateData).length > 0) { + await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {}); + } + } else if (!guestOnly && hasValidEmail) { + // Create a real active account (non-guest with email) + try { + const password = 'Hope123'; + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/users`, + { name: user.name, email: user.email, password, phoneNumber: phone || null } + ); + const createdUser = response.data.user || response.data; + if (!createdUser?.id) { res.status(400); throw new Error('User creation failed: No user ID returned'); } + userId = createdUser.id; + // Set derived preference on the new account + await prisma.user.update({ where: { id: userId }, data: { notificationPreference: derivedPref } }).catch(() => {}); + } catch (userErr) { + res.status(400); + throw new Error(`Failed to create user: ${userErr.response?.data?.message || userErr.message}`); + } + } else { + // Guest path: phone-only, guestOnly=true, or no valid email + const placeholderEmail = hasValidEmail + ? user.email + : `guest+${uuidv4().slice(0, 8)}@guest.local`; + const hashed = await hashPassword(uuidv4()); + const created = await prisma.user.create({ + data: { + id: uuidv4(), + name: user.name, + email: placeholderEmail, + password: hashed, + phoneNumber: phone || null, + isActive: false, + notificationPreference: derivedPref, + updatedAt: new Date(), + } + }); + userId = created.id; + } + + // Merge into existing non-cancelled registration if one exists, otherwise create new + const existingReg = await prisma.registration.findFirst({ + where: { userId, eventId, status: { not: 'cancelled' } }, + include: { registrationOptions: true } + }); + + let registration; + let isNewRegistration = false; + + if (existingReg) { + // Upsert each requested option into the existing registration (all in parallel) + await Promise.all(resolvedManualOptions.map(opt => { + const existing = existingReg.registrationOptions.find( + ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) + ); + if (existing) { + return prisma.registrationOption.update({ + where: { id: existing.id }, + data: { + quantity: existing.quantity + opt.quantity, + priceSnapshot: opt.priceSnapshot, + appliedTierId: opt.appliedTierId || null, + } + }); + } + return prisma.registrationOption.create({ + data: { + id: uuidv4(), + registrationId: existingReg.id, + eventOptionId: opt.eventOptionId, + quantity: opt.quantity, + variantId: opt.variantId || null, + appliedTierId: opt.appliedTierId || null, + priceSnapshot: opt.priceSnapshot, + } + }); + })); + // Recompute status based on totals across ALL current options and payments + const freshForStatus = await prisma.registration.findUnique({ + where: { id: existingReg.id }, + include: { + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true, + } + }); + const freshTotalDue = computeRegistrationTotalDue(freshForStatus, new Date()); + const freshTotalPaid = (freshForStatus?.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0); + let newExistingStatus; + if (freshTotalDue === 0 || freshTotalPaid >= freshTotalDue) newExistingStatus = 'paid'; + else if (freshTotalPaid > 0) newExistingStatus = 'partial_paid'; + else newExistingStatus = existingReg.status === 'cancelled' ? 'pending' : (existingReg.status || 'pending'); + await prisma.registration.update({ where: { id: existingReg.id }, data: { status: newExistingStatus, updatedAt: new Date() } }); + + registration = await prisma.registration.findUnique({ + where: { id: existingReg.id }, + include: { + registrationOptions: { include: { eventOption: true } }, + event: true, + user: { select: { id: true, name: true, email: true, phoneNumber: true } }, + }, + }); + registration.status = newExistingStatus; + } else { + isNewRegistration = true; + const registrationId = uuidv4(); + registration = await prisma.registration.create({ + data: { + id: registrationId, + userId, + eventId, + updatedAt: new Date(), + registrationOptions: { + create: resolvedManualOptions.map(option => ({ + id: uuidv4(), + eventOptionId: option.eventOptionId, + quantity: option.quantity, + variantId: option.variantId || null, + appliedTierId: option.appliedTierId || null, + priceSnapshot: option.priceSnapshot, + })), + }, + }, + include: { + registrationOptions: { include: { eventOption: true } }, + event: true, + user: { select: { id: true, name: true, email: true, phoneNumber: true } }, + }, + }); + } + + const registrationId = registration.id; + + // Auto mark paid and generate tickets if free and no required form (new registrations only) + let hasRequiredForm = false; + try { + const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }); + hasRequiredForm = !!(form && form.isRequired); + } catch {} + + // Auto-mark paid and generate tickets for free registrations (new OR existing that became free) + if (registration.status !== 'paid') try { + const totalDue = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0); + if (totalDue === 0) { + await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } }); + registration.status = 'paid'; + } + } catch {} + + // Generate/sync tickets for all paid registrations that have no required form + if (registration.status === 'paid' && !hasRequiredForm) try { + await generateTicketsForRegistration(registration.id); + } catch {} + + // For paid registrations, generate a Yoco checkout link to include in the email + let yocoPaymentUrl = null; + if (registration.status !== 'paid') { + try { + const { createRegistrationCheckoutInternal } = require('./paymentController'); + const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; + const checkout = await createRegistrationCheckoutInternal(registrationId, userId, { + successUrl: `${baseUrl}/payment/success`, + cancelUrl: `${baseUrl}/payment/cancel`, + failureUrl: `${baseUrl}/payment/failure`, + }); + yocoPaymentUrl = checkout.redirectUrl || null; + } catch (e) { + console.warn('Could not create Yoco checkout for self-service email:', e?.message || e); + } + } + + // Fire-and-forget: registration email first, then tickets (guarantees order) + const { sendSelfServiceRegistrationEmails, sendRegistrationUpdatedEmails } = require('../utils/notifications'); + const _ssRegId = registrationId; + const _ssUserId = userId; + const _ssPaymentUrl = yocoPaymentUrl; + const _ssFormRequired = hasRequiredForm; + const _ssIsNew = isNewRegistration; + const _ssNotifPref = derivedPref; + const _ssShouldEmailTickets = registration.status === 'paid' && !hasRequiredForm; + (async () => { + try { + if (_ssIsNew) { + await sendSelfServiceRegistrationEmails(_ssRegId, { paymentUrl: _ssPaymentUrl, formRequired: _ssFormRequired }); + } else { + await sendRegistrationUpdatedEmails(_ssRegId); + } + } catch (e) { console.error('Failed to send registration email:', e); } + if (_ssShouldEmailTickets) { + const mockReq = { user: { id: _ssUserId }, body: { registrationId: _ssRegId, channel: _ssNotifPref } }; + const mockRes = { status: () => mockRes, json: () => {} }; + try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Failed to email tickets after free self-service registration:', e); } + } + })(); + + return res.status(201).json(registration); + } catch (error) { + console.error(error); + return res.status(400).json({message: error.message}); + } +}; + +// @desc Update registration options (add/remove items) with constraints +// @route PUT /api/registrations/:id/options +// @access Private (owner or staff/admin) +const updateRegistrationOptions = async (req, res) => { + try { + const registrationId = req.params.id; + const userId = req.user.id; + const { options } = req.body; // [{ eventOptionId, quantity }] + + if (!Array.isArray(options)) { + res.status(400); + throw new Error('Options must be an array'); + } + + // Load registration with relations + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { + include: { + tickets: true, + eventOption: { include: { earlyBirdTiers: true, variants: true } } + } + }, + event: { include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } }, + payments: true, + } + }); + + if (!registration) { + res.status(404); + throw new Error('Registration not found'); + } + + // Authorization: owner or admin/supervisor/staff + if ( + registration.userId !== userId && + req.user.role !== 'admin' && + req.user.role !== 'supervisor' && + req.user.role !== 'staff' + ) { + res.status(403); + throw new Error('Not authorized to edit this registration'); + } + + // If event is in the past, block edit + if (registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) { + res.status(400); + throw new Error('This event has already ended; registration cannot be edited'); + } + + // registration.event is already loaded above, so check cashupStatus directly here + // rather than re-fetching via assertEventOpen. + if (registration.event?.cashupStatus === 'closed') { + res.status(400); + throw new Error('This event is closed. Reopen it (admin only) before making changes.'); + } + + // Validate provided options: must belong to same event and quantities >=1 + if (options.length === 0) { + res.status(400); + throw new Error('At least one option must be provided'); + } + + const validOptionIds = new Set((registration.event?.eventOptions || []).map(eo => eo.id)); + for (const opt of options) { + if (!opt || !opt.eventOptionId || !Number.isInteger(opt.quantity) || opt.quantity < 1) { + res.status(400); + throw new Error('Each option must include a valid eventOptionId and quantity >= 1'); + } + if (!validOptionIds.has(opt.eventOptionId)) { + res.status(400); + throw new Error('One or more options do not belong to this event'); + } + } + + // Compute totalPaid + const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0); + + // Merge duplicate entries for the same eventOptionId+variantId (defensive; normal + // callers send one entry per option/variant combo). + const mergedOptionsMap = new Map(); + for (const opt of options) { + const key = `${opt.eventOptionId}::${opt.variantId || ''}`; + if (mergedOptionsMap.has(key)) { + mergedOptionsMap.get(key).quantity += opt.quantity; + } else { + mergedOptionsMap.set(key, { ...opt }); + } + } + const mergedOptions = Array.from(mergedOptionsMap.values()); + + // Resolve pricing for each incoming option (variant-aware, with stock check) + const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo])); + const resolvedUpdateOptions = []; + for (const opt of mergedOptions) { + const eventOption = eventOptionsMap.get(opt.eventOptionId); + const variantId = opt.variantId || null; + let priceSnapshot = null; + let appliedTierId = null; + try { + if (variantId) { + const variantResolved = await resolveVariantTierPrice(eventOption, variantId, opt.quantity); + priceSnapshot = variantResolved.price; + appliedTierId = variantResolved.tierId; + } else { + const resolved = await resolveOptionPrice(eventOption, opt.quantity); + priceSnapshot = resolved.price; + appliedTierId = resolved.tierId; + } + } catch (e) { + priceSnapshot = Number(eventOption?.price || 0); + } + resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId }); + } + + const newTotalDue = resolvedUpdateOptions.reduce((sum, opt) => sum + (opt.priceSnapshot || 0) * (opt.quantity || 0), 0); + + if (newTotalDue < totalPaid) { + res.status(400); + throw new Error('Cannot reduce items below the amount already paid'); + } + + // Group existing registrationOptions by eventOptionId::variantId so tickets that + // have already been issued are never deleted, only ever updated in place. + const oldByKey = new Map(); + for (const ro of registration.registrationOptions) { + const key = `${ro.eventOptionId}::${ro.variantId || ''}`; + if (!oldByKey.has(key)) oldByKey.set(key, []); + oldByKey.get(key).push(ro); + } + + // Per-item floor: a ticket is only ever created once a registration is paid, and it is + // never deleted or shrunk — only grown. So an option can never be reduced (or removed) + // below the quantity of any ticket already issued for it. + const newKeys = new Set(resolvedUpdateOptions.map(opt => `${opt.eventOptionId}::${opt.variantId || ''}`)); + const newQtyByKey = new Map(resolvedUpdateOptions.map(opt => [`${opt.eventOptionId}::${opt.variantId || ''}`, opt.quantity || 0])); + for (const [key, rows] of oldByKey) { + const ticketQty = rows.reduce((sum, ro) => sum + (ro.tickets || []).reduce((s, t) => s + (t.quantity || 0), 0), 0); + if (ticketQty <= 0) continue; + const newQty = newQtyByKey.get(key) || 0; + if (newQty < ticketQty) { + res.status(400); + throw new Error(`Cannot reduce "${rows[0].eventOption?.name || 'item'}" below the ${ticketQty} ticket(s) already issued`); + } + } + + // Differential update: update matching options in place, create genuinely new ones, + // and only delete options that were removed entirely. The floor check above guarantees + // a removed key never has tickets — the guard below turns that into a hard invariant + // instead of an assumption: tickets are never deleted. + await prisma.$transaction(async (tx) => { + for (const [key, rows] of oldByKey) { + if (newKeys.has(key)) continue; + const ticketedRows = rows.filter(ro => (ro.tickets || []).length > 0); + if (ticketedRows.length > 0) { + throw new Error(`Cannot remove "${rows[0].eventOption?.name || 'item'}" — tickets have already been issued for it`); + } + await tx.registrationOption.deleteMany({ where: { id: { in: rows.map(r => r.id) } } }); + } + + for (const opt of resolvedUpdateOptions) { + const key = `${opt.eventOptionId}::${opt.variantId || ''}`; + const existingRows = oldByKey.get(key); + if (existingRows && existingRows.length > 0) { + const [primary, ...dupes] = existingRows; + await tx.registrationOption.update({ + where: { id: primary.id }, + data: { + quantity: opt.quantity, + appliedTierId: opt.appliedTierId || null, + priceSnapshot: opt.priceSnapshot, + } + }); + for (const dup of dupes) { + if ((dup.tickets || []).length > 0) { + await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } }); + } + await tx.registrationOption.delete({ where: { id: dup.id } }); + } + } else { + await tx.registrationOption.create({ + data: { + id: uuidv4(), + registrationId, + eventOptionId: opt.eventOptionId, + quantity: opt.quantity, + variantId: opt.variantId || null, + appliedTierId: opt.appliedTierId || null, + priceSnapshot: opt.priceSnapshot, + } + }); + } + } + + // Update updatedAt and potentially status based on payments vs due + let newStatus = registration.status; + if (totalPaid >= newTotalDue) newStatus = 'paid'; + else if (totalPaid > 0) newStatus = 'partial_paid'; + else newStatus = 'pending'; + + await tx.registration.update({ + where: { id: registrationId }, + data: { updatedAt: new Date(), status: newStatus } + }); + }); + + // Return updated registration + const updated = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { include: { eventOption: true } }, + event: true, + user: { select: { id: true, name: true, email: true, phoneNumber: true } }, + payments: true, + } + }); + + const { sendRegistrationUpdatedEmails } = require('../utils/notifications'); + sendRegistrationUpdatedEmails(registrationId).catch(e => console.error('Failed to send registration updated emails:', e)); + + // If registration is now paid, regenerate tickets and send them + if (updated.status === 'paid') { + (async () => { + try { + await generateTicketsForRegistration(registrationId); + const mockReq = { user: { id: updated.userId }, body: { registrationId } }; + const mockRes = { status: () => mockRes, json: () => {} }; + await emailTickets(mockReq, mockRes); + } catch (e) { + console.error('[updateRegistrationOptions] Failed to generate/send tickets:', e?.message || e); + } + })(); + } + + return res.json(updated); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Submit form responses for a registration +// @route POST /api/registrations/:id/forms/responses +// @access Private +const submitFormResponses = async (req, res) => { + try { + const registrationId = req.params.id; + const userId = req.user?.id || null; + const { responses } = req.body; // [{ answers: { fieldId: value } }] + + // load registration with options and event form + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { include: { eventOption: true } }, + event: true, + } + }); + if (!registration) { res.status(404); throw new Error('Registration not found'); } + // Guests (no auth) may submit by knowing the registrationId; authenticated users must be owner or staff+ + if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) { + res.status(403); throw new Error('Not authorized'); + } + + // fetch form + let form = null; + try { + form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId }, include: { fields: true } }); + } catch (e) {} + if (!form) { + return res.json({ message: 'No form for this event. Nothing to submit.' }); + } + + const fields = (form.fields || []).sort((a,b) => (a.order||0)-(b.order||0)); + const answerableFields = fields.filter(f => f.type !== 'statement' && f.type !== 'paragraph'); + + const mainTickets = registration.registrationOptions.filter(ro => ro.eventOption?.isMainTicket).reduce((s, ro) => s + (ro.quantity || 0), 0); + const toSubmit = Array.isArray(responses) ? responses : []; + + if (toSubmit.length === 0) { res.status(400); throw new Error('No responses provided'); } + if (toSubmit.length > mainTickets) { res.status(400); throw new Error(`Too many forms submitted. Expected at most ${mainTickets}`); } + + // Create responses and answers + const created = []; + for (const resp of toSubmit) { + const respId = uuidv4(); + const fr = await prisma.formResponse.create({ data: { id: respId, registrationId } }); + const ansMap = resp?.answers || {}; + for (const f of answerableFields) { + const raw = ansMap[f.id]; + // If field required at field-level, enforce non-empty + if (f.isRequired && (raw === undefined || raw === null || String(raw).trim() === '')) { + res.status(400); + throw new Error(`Missing answer for: ${f.label}`); + } + if (raw !== undefined && raw !== null && String(raw).length > 0) { + await prisma.formAnswer.create({ data: { id: uuidv4(), responseId: respId, fieldId: f.id, value: String(raw) } }); + } + } + created.push(fr); + } + + // After submitting responses, if registration is already paid, generate and email tickets now. + let generatedTickets = []; + try { + const regAfter = await prisma.registration.findUnique({ where: { id: registrationId } }); + if (regAfter && regAfter.status === 'paid') { + generatedTickets = await generateTicketsForRegistration(registrationId); + if (generatedTickets && generatedTickets.length > 0) { + try { + const mockReq = { user: { id: regAfter.userId }, body: { registrationId } }; + const mockRes = { status: () => mockRes, json: () => {} }; + await emailTickets(mockReq, mockRes); + } catch (emailErr) { + console.error('Post-form ticket email failed:', emailErr?.message || emailErr); + } + } + } + } catch (e) { + console.error('Post-form ticket generation attempt failed:', e?.message || e); + } + + res.status(201).json({ message: 'Responses submitted', count: created.length, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined }); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// @desc Replace (edit) form responses for a registration (staff/admin/supervisor) +// @route PUT /api/registrations/:id/forms/responses +// @access Private/Staff +const replaceFormResponses = async (req, res) => { + try { + const registrationId = req.params.id; + const { responses } = req.body; // [{ answers: { fieldId: value } }] + + // Authorization: staff or above only for replacing existing responses + if (!['admin','supervisor','staff'].includes(req.user.role)) { + res.status(403); + throw new Error('Not authorized'); + } + + // Load registration and form + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { include: { eventOption: true } }, + event: true, + } + }); + if (!registration) { res.status(404); throw new Error('Registration not found'); } + + let form = null; + try { + form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId }, include: { fields: true } }); + } catch {} + if (!form) { return res.json({ message: 'No form for this event. Nothing to save.' }); } + + const fields = (form.fields || []).sort((a,b) => (a.order||0)-(b.order||0)); + const answerableFields = fields.filter(f => f.type !== 'statement' && f.type !== 'paragraph'); + const mainTickets = registration.registrationOptions.filter(ro => ro.eventOption?.isMainTicket).reduce((s, ro) => s + (ro.quantity || 0), 0); + const toSubmit = Array.isArray(responses) ? responses : []; + + if (toSubmit.length === 0) { res.status(400); throw new Error('No responses provided'); } + if (toSubmit.length > mainTickets) { res.status(400); throw new Error(`Too many forms submitted. Expected at most ${mainTickets}`); } + + // Replace transactionally + await prisma.$transaction(async (tx) => { + // Delete existing answers and responses for this registration + const existing = await tx.formResponse.findMany({ where: { registrationId }, select: { id: true } }); + const ids = existing.map(e => e.id); + if (ids.length > 0) { + await tx.formAnswer.deleteMany({ where: { responseId: { in: ids } } }); + await tx.formResponse.deleteMany({ where: { id: { in: ids } } }); + } + // Create new set + for (const resp of toSubmit) { + const respId = uuidv4(); + await tx.formResponse.create({ data: { id: respId, registrationId } }); + const ansMap = resp?.answers || {}; + for (const f of answerableFields) { + const raw = ansMap[f.id]; + if (f.isRequired && (raw === undefined || raw === null || String(raw).trim() === '')) { + res.status(400); + throw new Error(`Missing answer for: ${f.label}`); + } + if (raw !== undefined && raw !== null && String(raw).length > 0) { + await tx.formAnswer.create({ data: { id: uuidv4(), responseId: respId, fieldId: f.id, value: String(raw) } }); + } + } + } + // Touch registration updatedAt + await tx.registration.update({ where: { id: registrationId }, data: { updatedAt: new Date() } }); + }); + + // Attempt ticket generation and email if already paid + try { + const regAfter = await prisma.registration.findUnique({ where: { id: registrationId }, include: { user: { select: { id: true } } } }); + if (regAfter && regAfter.status === 'paid') { + const newTickets = await generateTicketsForRegistration(registrationId); + if (newTickets && newTickets.length > 0) { + try { + const mockReq = { user: { id: regAfter.userId }, body: { registrationId } }; + const mockRes = { status: () => mockRes, json: () => {} }; + await emailTickets(mockReq, mockRes); + } catch (emailErr) { + console.error('Error emailing tickets after form replacement:', emailErr); + } + } + } + } catch {} + + return res.json({ message: 'Responses saved' }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Get saved draft for attendee forms for a registration (owner or staff) +// @route GET /api/registrations/:id/forms/draft +// @access Private +const getFormDraft = async (req, res) => { + try { + const registrationId = req.params.id; + const userId = req.user?.id || null; + + // Ensure Prisma Client has the FormDraft model (migration + generate applied) + const hasFormDraftRead = !!(prisma && prisma.formDraft && typeof prisma.formDraft.findUnique === 'function'); + if (!hasFormDraftRead) { + return res.status(400).json({ + message: 'Draft storage is not available on the server.', + hint: 'Apply Prisma migrations and regenerate the Prisma Client, then restart the server.', + next: [ + 'cd backend', + 'npx prisma migrate dev -n add-form-draft', + 'npx prisma generate', + 'restart the backend server' + ] + }); + } + + // Load registration to verify access + const registration = await prisma.registration.findUnique({ where: { id: registrationId } }); + if (!registration) { res.status(404); throw new Error('Registration not found'); } + if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) { + res.status(403); throw new Error('Not authorized'); + } + + // Find draft. If staff/admin, we still scope the draft to the registration owner (so they can resume later). + const ownerUserId = registration.userId; + const draft = await prisma.formDraft.findUnique({ where: { registrationId_userId: { registrationId, userId: ownerUserId } } }); + if (!draft) return res.json({ data: {}, updatedAt: null }); + return res.json({ data: draft.data || {}, updatedAt: draft.updatedAt }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Save draft for attendee forms for a registration (owner or staff). Does not mark as completed. +// @route PUT /api/registrations/:id/forms/draft +// @access Private +const saveFormDraft = async (req, res) => { + try { + const registrationId = req.params.id; + const userId = req.user?.id || null; + const { data } = req.body; // JSON object mapping attendeeIndex -> { fieldId: value } + + if (typeof data !== 'object' || data == null) { + res.status(400); throw new Error('Invalid draft payload'); + } + + // Ensure Prisma Client has the FormDraft model (migration + generate applied) + const hasFormDraftWrite = !!(prisma && prisma.formDraft && typeof prisma.formDraft.upsert === 'function'); + if (!hasFormDraftWrite) { + return res.status(400).json({ + message: 'Draft storage is not available on the server.', + hint: 'Apply Prisma migrations and regenerate the Prisma Client, then restart the server.', + next: [ + 'cd backend', + 'npx prisma migrate dev -n add-form-draft', + 'npx prisma generate', + 'restart the backend server' + ] + }); + } + + // Load registration to verify access + const registration = await prisma.registration.findUnique({ where: { id: registrationId } }); + if (!registration) { res.status(404); throw new Error('Registration not found'); } + if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) { + res.status(403); throw new Error('Not authorized'); + } + + const ownerUserId = registration.userId; + + // Upsert by (registrationId, userId) unique composite + const saved = await prisma.formDraft.upsert({ + where: { registrationId_userId: { registrationId, userId: ownerUserId } }, + update: { data, updatedAt: new Date() }, + create: { registrationId, userId: ownerUserId, data }, + }); + + return res.json({ message: 'Draft saved', updatedAt: saved.updatedAt }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +module.exports = { + createRegistration, + getRegistrations, + getUserRegistrations, + getRegistrationById, + updateRegistrationStatus, + cancelRegistration, + getRegistrationsByEvent, + createManualRegistration, + updateRegistrationOptions, + submitFormResponses, + replaceFormResponses, + getFormDraft, + saveFormDraft, +}; \ No newline at end of file diff --git a/backend/src/controllers/reportController.js b/backend/src/controllers/reportController.js new file mode 100644 index 0000000..bd166e7 --- /dev/null +++ b/backend/src/controllers/reportController.js @@ -0,0 +1,275 @@ +const PDFDocument = require('pdfkit'); +const fs = require('fs'); +const path = require('path'); +const nodemailer = require('nodemailer'); + +// Utility: draw a table +function drawTable(doc, startX, startY, colWidths, rows, header) { + let y = startY; + doc.font('Helvetica-Bold'); + if (header && header.length) { + let x = startX; + header.forEach((h, i) => { + const w = colWidths[i] || 80; + doc.rect(x, y, w, 20).stroke(); + doc.text(String(h || ''), x + 4, y + 6, { width: w - 8 }); + x += w; + }); + y += 20; + } + doc.font('Helvetica'); + rows.forEach((row) => { + let x = startX; + row.forEach((cell, i) => { + const w = colWidths[i] || 80; + const h = 18; + doc.rect(x, y, w, h).stroke(); + doc.text(String(cell ?? ''), x + 4, y + 4, { width: w - 8 }); + x += w; + }); + y += 18; + // New page if overflow + if (y > doc.page.height - 40) { + doc.addPage(); + y = 20; + } + }); +} + +function a4Doc(orientation = 'portrait') { + return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' }); +} + +// POST /api/reports/pdf +// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } } +const generatePdf = async (req, res) => { + try { + const { title, kind, table, layered, orientation } = req.body || {}; + res.setHeader('Content-Type', 'application/pdf'); + const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`; + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + + const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait'); + doc.pipe(res); + + // Title + doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' }); + doc.moveDown(0.5); + + if (kind === 'table' && table && Array.isArray(table.rows)) { + const columns = Array.isArray(table.columns) ? table.columns : []; + const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1); + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + // Slightly wider first column to mimic site tables + const baseWidth = Math.floor(pageWidth / Math.max(1, colCount)); + const colWidths = new Array(colCount).fill(baseWidth); + if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2); + + // Draw header band + if (columns.length) { + let x = doc.page.margins.left; + const y = doc.y; + doc.save(); + doc.rect(x, y, pageWidth, 22).fill('#f3f4f6'); + doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11); + columns.forEach((h, i) => { + const w = colWidths[i] || baseWidth; + doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 }); + x += w; + }); + doc.restore(); + doc.moveDown(1.6); + } + + // Zebra rows + const rows = table.rows; + rows.forEach((row, idx) => { + const rowY = doc.y; + const rowH = 18; + const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb'; + doc.save(); + doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore(); + let x = doc.page.margins.left; + row.forEach((cell, i) => { + const w = colWidths[i] || baseWidth; + // Cell text + doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 }); + // Vertical separators similar to table borders + doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke(); + x += w; + }); + // Right border + doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke(); + doc.moveDown(1.1); + if (doc.y > doc.page.height - 40) { + doc.addPage(); + } + }); + // Bottom border + doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke(); + + } else if (kind === 'layered' && layered && Array.isArray(layered.sections)) { + if (layered.header) { + doc.font('Helvetica-Bold').fontSize(13).text(layered.header); + doc.moveDown(0.3); + } + doc.font('Helvetica').fontSize(11); + for (const section of layered.sections) { + doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false }); + doc.moveDown(0.15); + doc.font('Helvetica').fontSize(10); + if (Array.isArray(section.items) && section.items.length) { + for (const item of section.items) { + // Bullet dot + doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke(); + doc.fillColor('#111827'); + doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 }); + doc.moveDown(0.2); + } + } else { + doc.text('No items'); + } + doc.moveDown(0.5); + if (doc.y > doc.page.height - 60) doc.addPage(); + } + } else { + doc.font('Helvetica').text('No content'); + } + + doc.end(); + } catch (e) { + res.status(400).json({ message: e.message }); + } +}; + +// POST /api/reports/email +// body: { title, kind, table?, layered?, subject?, body? } +const emailPdf = async (req, res) => { + try { + const { title, kind, table, layered, subject, body, orientation } = req.body || {}; + const user = req.user; + if (!user || !user.email) { + res.status(400); + throw new Error('User email not available'); + } + + // Ensure temp dir + const tempDir = path.join(__dirname, '..', '..', 'temp'); + if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); + const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_')); + + // Build PDF to file + await new Promise((resolve, reject) => { + const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait'); + const ws = fs.createWriteStream(filePath); + doc.pipe(ws); + + doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report'); + doc.moveDown(0.5); + + if (kind === 'table' && table && Array.isArray(table.rows)) { + const columns = Array.isArray(table.columns) ? table.columns : []; + const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1); + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + // Slightly wider first column + const baseWidth = Math.floor(pageWidth / Math.max(1, colCount)); + const colWidths = new Array(colCount).fill(baseWidth); + if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2); + + // Header band + if (columns.length) { + let x = doc.page.margins.left; + const y = doc.y; + doc.save(); + doc.rect(x, y, pageWidth, 22).fill('#f3f4f6'); + doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11); + columns.forEach((h, i) => { + const w = colWidths[i] || baseWidth; + doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 }); + x += w; + }); + doc.restore(); + doc.moveDown(1.6); + } + + // Rows zebra + const rows = table.rows; + rows.forEach((row, idx) => { + const rowY = doc.y; + const rowH = 18; + const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb'; + doc.save(); + doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore(); + let x = doc.page.margins.left; + row.forEach((cell, i) => { + const w = colWidths[i] || baseWidth; + doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 }); + doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke(); + x += w; + }); + doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke(); + doc.moveDown(1.1); + if (doc.y > doc.page.height - 40) { + doc.addPage(); + } + }); + doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke(); + + } else if (kind === 'layered' && layered && Array.isArray(layered.sections)) { + if (layered.header) { + doc.font('Helvetica-Bold').fontSize(13).text(layered.header); + doc.moveDown(0.3); + } + doc.font('Helvetica').fontSize(11); + for (const section of layered.sections) { + doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false }); + doc.moveDown(0.15); + doc.font('Helvetica').fontSize(10); + if (Array.isArray(section.items) && section.items.length) { + for (const item of section.items) { + doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke(); + doc.fillColor('#111827'); + doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 }); + doc.moveDown(0.2); + } + } else { + doc.text('No items'); + } + doc.moveDown(0.5); + if (doc.y > doc.page.height - 60) doc.addPage(); + } + } else { + doc.font('Helvetica').text('No content'); + } + + doc.end(); + ws.on('finish', resolve); + ws.on('error', reject); + }); + + // Send email using nodemailer (same config as tickets) + const transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: process.env.EMAIL_PORT, + secure: process.env.EMAIL_PORT === '465', + auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS } + }); + + await transporter.sendMail({ + from: process.env.EMAIL_FROM, + to: user.email, + subject: subject || (title ? `${title} PDF` : 'Report PDF'), + text: body || 'Please find your report attached.', + attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }] + }); + + // Clean + try { fs.unlinkSync(filePath); } catch {} + + res.json({ message: `Report emailed to ${user.email}` }); + } catch (e) { + res.status(400).json({ message: e.message }); + } +}; + +module.exports = { generatePdf, emailPdf }; diff --git a/backend/src/controllers/scheduledEmailsController.js b/backend/src/controllers/scheduledEmailsController.js new file mode 100644 index 0000000..0276ca8 --- /dev/null +++ b/backend/src/controllers/scheduledEmailsController.js @@ -0,0 +1,109 @@ +const { listJobs, getJob, updateJob, deleteJob } = require('../utils/scheduledEmails'); + +// Normalize job for client UI +function toClient(job) { + const kind = job.broadcast ? 'broadcast' : (job.eventId ? 'attendees' : 'unknown'); + const subject = job?.payload?.subject || ''; + const html = job?.payload?.html || ''; + const text = job?.payload?.text || ''; + return { + id: job.id, + kind, + eventId: job.eventId || null, + broadcast: !!job.broadcast, + scheduledAt: job.scheduledAt, + createdAt: job.createdAt, + status: job.status, + attempts: job.attempts || 0, + sentAt: job.sentAt || null, + lastError: job.lastError || null, + subject, + hasHtml: !!html, + hasText: !!text, + }; +} + +// GET /api/scheduled-emails +// Returns jobs excluding emails sent more than a week ago +const listScheduledEmails = async (req, res) => { + try { + const raw = listJobs(); + const now = new Date(); + const weekMs = 7 * 24 * 60 * 60 * 1000; + const filtered = raw.filter(j => { + if (j.status === 'sent' && j.sentAt) { + const sentAt = new Date(j.sentAt).getTime(); + return (now.getTime() - sentAt) <= weekMs; + } + // Include queued, sending, error by default + return true; + }) + // Provide most-relevant first: queued -> sending -> error -> recent sent + .sort((a, b) => { + const order = { queued: 0, sending: 1, error: 2, sent: 3 }; + const oa = order[a.status] ?? 99; + const ob = order[b.status] ?? 99; + if (oa !== ob) return oa - ob; + // Then by scheduledAt asc + return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(); + }) + .map(toClient); + + return res.json({ jobs: filtered }); + } catch (e) { + return res.status(400).json({ message: e?.message || 'Failed to list scheduled emails' }); + } +}; + +// PATCH /api/scheduled-emails/:id +// Allows editing scheduledAt, subject, html/text on queued jobs only +const updateScheduledEmail = async (req, res) => { + try { + const { id } = req.params; + const job = getJob(id); + if (!job) return res.status(404).json({ message: 'Job not found' }); + if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' }); + + const { scheduledAt, subject, html, text } = req.body || {}; + + const patch = {}; + if (scheduledAt) { + const when = new Date(scheduledAt); + if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' }); + patch.scheduledAt = when.toISOString(); + } + if (subject != null || html != null || text != null) { + const payload = { ...(job.payload || {}) }; + if (subject != null) payload.subject = subject; + if (html != null || text != null) { + // If html provided explicitly, set html; if text provided, set text + if (html != null) payload.html = html; + if (text != null) payload.text = text; + } + patch.payload = payload; + } + + const updated = updateJob(id, patch); + return res.json({ message: 'Updated', job: toClient(updated) }); + } catch (e) { + return res.status(400).json({ message: e?.message || 'Failed to update job' }); + } +}; + +// DELETE /api/scheduled-emails/:id +// Only queued jobs can be removed +const deleteScheduledEmail = async (req, res) => { + try { + const { id } = req.params; + const job = getJob(id); + if (!job) return res.status(404).json({ message: 'Job not found' }); + if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be deleted' }); + const ok = deleteJob(id); + if (!ok) return res.status(404).json({ message: 'Job not found' }); + return res.json({ message: 'Deleted' }); + } catch (e) { + return res.status(400).json({ message: e?.message || 'Failed to delete job' }); + } +}; + +module.exports = { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail }; diff --git a/backend/src/controllers/sectionController.js b/backend/src/controllers/sectionController.js new file mode 100644 index 0000000..649616f --- /dev/null +++ b/backend/src/controllers/sectionController.js @@ -0,0 +1,190 @@ +const prisma = require('../config/db'); +const { v4: uuidv4 } = require('uuid'); + +// +// @desc Get sections (optionally by event) +// @route GET /api/sections?eventId=xxx +// @access Private/Staff +// +const getSections = async (req, res) => { + try { + const { eventId } = req.query; + + const where = eventId && eventId !== 'all' + ? { eventId } + : undefined; + + const sections = await prisma.section.findMany({ + where, + include: { + allowedOptions: { + include: { + eventOption: true + } + }, + event: { + select: { id: true, title: true } + } + }, + orderBy: { name: 'asc' } + }); + + res.json(sections); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// +// @desc Create section +// @route POST /api/sections +// @access Private/Admin/Supervisor +// +const createSection = async (req, res) => { + try { + const { eventId, name, allowedOptionIds } = req.body; + + if (!eventId || !name) { + res.status(400); + throw new Error('Event and section name are required'); + } + + const event = await prisma.event.findUnique({ + where: { id: eventId } + }); + + if (!event) { + res.status(404); + throw new Error('Event not found'); + } + + const section = await prisma.section.create({ + data: { + id: uuidv4(), + eventId, + name, + updatedAt: new Date() + } + }); + + // Attach allowed options if provided + if (Array.isArray(allowedOptionIds) && allowedOptionIds.length > 0) { + await prisma.sectionOption.createMany({ + data: allowedOptionIds.map(optionId => ({ + id: uuidv4(), + sectionId: section.id, + eventOptionId: optionId + })), + skipDuplicates: true + }); + } + + const fullSection = await prisma.section.findUnique({ + where: { id: section.id }, + include: { + allowedOptions: { + include: { eventOption: true } + } + } + }); + + res.status(201).json(fullSection); + + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// +// @desc Update section +// @route PUT /api/sections/:id +// @access Private/Admin/Supervisor +// +const updateSection = async (req, res) => { + try { + const { name, allowedOptionIds } = req.body; + + const section = await prisma.section.findUnique({ + where: { id: req.params.id } + }); + + if (!section) { + res.status(404); + throw new Error('Section not found'); + } + + const updatedSection = await prisma.section.update({ + where: { id: req.params.id }, + data: { + name: name || section.name, + updatedAt: new Date() + } + }); + + // If allowed options supplied → reset them + if (Array.isArray(allowedOptionIds)) { + + await prisma.sectionOption.deleteMany({ + where: { sectionId: section.id } + }); + + if (allowedOptionIds.length > 0) { + await prisma.sectionOption.createMany({ + data: allowedOptionIds.map(optionId => ({ + id: uuidv4(), + sectionId: section.id, + eventOptionId: optionId + })) + }); + } + } + + const fullSection = await prisma.section.findUnique({ + where: { id: section.id }, + include: { + allowedOptions: { + include: { eventOption: true } + } + } + }); + + res.json(fullSection); + + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +// +// @desc Delete section +// @route DELETE /api/sections/:id +// @access Private/Admin +// +const deleteSection = async (req, res) => { + try { + const section = await prisma.section.findUnique({ + where: { id: req.params.id } + }); + + if (!section) { + res.status(404); + throw new Error('Section not found'); + } + + await prisma.section.delete({ + where: { id: section.id } + }); + + res.json({ message: 'Section deleted' }); + + } catch (error) { + res.status(400).json({ message: error.message }); + } +}; + +module.exports = { + getSections, + createSection, + updateSection, + deleteSection +}; \ No newline at end of file diff --git a/backend/src/controllers/settingsController.js b/backend/src/controllers/settingsController.js new file mode 100644 index 0000000..ede9ff5 --- /dev/null +++ b/backend/src/controllers/settingsController.js @@ -0,0 +1,311 @@ +const prisma = require('../config/db'); +const { hashPassword, generateToken } = require('../config/auth'); +const { safeErrorMessage } = require('../utils/errorUtils'); +const { v4: uuidv4 } = require('uuid'); +const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache'); +const { encrypt, decrypt, isEncrypted } = require('../utils/encryption'); + +// Keys safe to return without auth — includes legal keys needed by public legal pages +const PUBLIC_KEYS = [ + 'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address', + 'accent_color', 'logo_url', 'setup_complete', 'app_base_url', + // Legal pages + 'legal_operator_name', 'legal_io_name', 'legal_io_email', + 'legal_website_url', 'legal_effective_date', +]; + +// @desc Get public app settings +// @route GET /api/settings +// @access Public +const getSettings = async (req, res) => { + try { + const rows = await prisma.appSetting.findMany({ where: { key: { in: PUBLIC_KEYS } } }); + const settings = {}; + for (const r of rows) settings[r.key] = r.value; + res.json(settings); + } catch (e) { + res.status(500).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc Get ALL app settings (admin view — encrypted fields masked) +// @route GET /api/settings/all +// @access Admin +const getAllSettings = async (req, res) => { + try { + const rows = await prisma.appSetting.findMany(); + const settings = {}; + for (const r of rows) { + if (ENCRYPTED_KEYS.has(r.key)) { + // Return a sentinel so the UI knows the value is set, without exposing it. + // smtp_user (email address) we can safely return as-is after decryption so + // the admin can see what address is configured; smtp_pass we fully mask. + if (r.key === 'smtp_pass') { + settings[r.key] = r.value ? '••••••••' : ''; + } else { + // smtp_user — decrypt and return so admin can see/edit it + try { + settings[r.key] = isEncrypted(r.value) ? decrypt(r.value) : r.value; + } catch { + settings[r.key] = ''; + } + } + } else { + settings[r.key] = r.value; + } + } + res.json(settings); + } catch (e) { + res.status(500).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc Upsert one or more app settings +// @route PUT /api/settings +// @access Admin +const updateSettings = async (req, res) => { + try { + const updates = req.body; + if (!updates || typeof updates !== 'object') { + res.status(400); throw new Error('Body must be a key→value object'); + } + + const ops = Object.entries(updates) + .filter(([, v]) => v !== undefined && v !== null) + .map(([key, value]) => { + let storedValue = String(value); + + // Encrypt sensitive keys before storing + if (ENCRYPTED_KEYS.has(key)) { + // If the frontend sends the masking sentinel back, skip this key (user didn't change it) + if (storedValue === '••••••••') return null; + if (storedValue === '') { + // Blank = clear the setting + return prisma.appSetting.upsert({ + where: { key }, + update: { value: '' }, + create: { key, value: '' }, + }); + } + storedValue = encrypt(storedValue); + } + + return prisma.appSetting.upsert({ + where: { key }, + update: { value: storedValue }, + create: { key, value: storedValue }, + }); + }) + .filter(Boolean); // remove nulls (masked password skips) + + if (ops.length) await prisma.$transaction(ops); + invalidateSettingsCache(); + await warmCache(); // ensure in-memory cache reflects the new values before responding + res.json({ message: 'Settings saved' }); + } catch (e) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc Check whether first-time setup is still needed +// @route GET /api/settings/needs-setup +// @access Public +const needsSetup = async (req, res) => { + try { + const done = await prisma.appSetting.findUnique({ where: { key: 'setup_complete' } }); + res.json({ needsSetup: done?.value !== 'true' }); + } catch { + // DB unreachable — don't block the app + res.json({ needsSetup: false }); + } +}; + +// @desc Register the first admin account during setup — returns a JWT for use in subsequent setup steps +// @route POST /api/setup/register +// @access Public (one-time only — blocked once users exist) +const setupRegister = async (req, res) => { + try { + const count = await prisma.user.count(); + if (count > 0) { + res.status(403); throw new Error('Setup has already been completed'); + } + + const { adminName, adminEmail, adminPassword } = req.body; + + if (!adminName?.trim()) { res.status(400); throw new Error('Admin name is required'); } + if (!adminEmail?.trim()) { res.status(400); throw new Error('Admin email is required'); } + if (!adminPassword || adminPassword.length < 8) { + res.status(400); throw new Error('Password must be at least 8 characters'); + } + + const hashed = await hashPassword(adminPassword); + const user = await prisma.user.create({ + data: { + id: uuidv4(), + name: adminName.trim(), + email: adminEmail.trim().toLowerCase(), + password: hashed, + role: 'admin', + isActive: true, + }, + }); + + const token = generateToken(user.id, user.role, user.tokenVersion ?? 0); + res.json({ token, name: user.name, email: user.email }); + } catch (e) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc Complete first-time setup: persist settings (admin must already be registered via /api/setup/register) +// @route POST /api/setup +// @access Admin (use token returned by /api/setup/register) +const runSetup = async (req, res) => { + try { + const setupDone = await prisma.appSetting.findUnique({ where: { key: 'setup_complete' } }); + if (setupDone?.value === 'true') { + res.status(403); throw new Error('Setup has already been completed'); + } + + const { settings = {} } = req.body; + + const toSave = { ...settings, setup_complete: 'true' }; + const ops = Object.entries(toSave) + .filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '') + .map(([key, value]) => { + let storedValue = String(value); + if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue); + return prisma.appSetting.upsert({ + where: { key }, + update: { value: storedValue }, + create: { key, value: storedValue }, + }); + }); + await prisma.$transaction(ops); + invalidateSettingsCache(); + await warmCache(); + + res.json({ message: 'Setup complete. You can now log in.' }); + } catch (e) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) }); + } +}; + +/** + * Translates raw nodemailer / Node.js network errors into plain-language messages + * suitable for display to an admin who may not know what ECONNREFUSED means. + */ +function friendlySmtpError(e) { + const code = e.code || ''; + const msg = (e.message || '').toLowerCase(); + const resp = (e.response || '').toLowerCase(); + + // Authentication failures (530 = Microsoft "Client not authenticated", 535 = standard auth failure) + if (code === 'EAUTH' || e.responseCode === 535 || e.responseCode === 530 || msg.includes('invalid login') || msg.includes('username and password') || msg.includes('not authenticated') || resp.includes('badcredentials') || resp.includes('authentication')) { + return 'Authentication failed — check your SMTP username and password.'; + } + + // Wrong certificate / TLS mismatch + if (code === 'ESOCKET' && (msg.includes('wrong version') || msg.includes('ssl') || msg.includes('tls'))) { + return 'TLS/SSL error — try toggling the "Use TLS/SSL" option or switching the port between 465 and 587.'; + } + if (msg.includes('unable_to_verify') || msg.includes('self signed') || msg.includes('certificate')) { + return 'SSL certificate error — the server\'s certificate could not be verified. Check the port and TLS setting.'; + } + + // Connection refused or timed out + if (code === 'ECONNREFUSED') { + return 'Connection refused — no mail server responded on that host and port. Check the host and port settings.'; + } + if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT' || msg.includes('timed out')) { + return 'Connection timed out — the server did not respond in time. Check the host and port, or try a different port.'; + } + + // DNS / hostname not found + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { + return 'Host not found — the SMTP hostname could not be resolved. Check for typos in the server address.'; + } + + // Network unreachable + if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH') { + return 'Network unreachable — the server could not be reached. Check your network connection and the host address.'; + } + + // Connection reset + if (code === 'ECONNRESET') { + return 'Connection was reset by the server — this can indicate a port mismatch or a firewall block.'; + } + + // Generic SMTP error with a response code + if (e.responseCode) { + return `SMTP error ${e.responseCode}: ${e.response || e.message}`; + } + + // Fallback — strip overly long technical strings but keep it readable + const raw = e.message || 'Unknown error'; + const trimmed = raw.length > 120 ? raw.slice(0, 120) + '…' : raw; + return `Could not connect: ${trimmed}`; +} + +// @desc Test SMTP connection using current settings (DB or env fallbacks) +// Optionally accepts { host, port, secure, user, pass, from } in the request +// body to test unsaved values without saving them first. +// @route POST /api/settings/test-smtp +// @access Admin +const testSmtp = async (req, res) => { + const nodemailer = require('nodemailer'); + const { getSettingSync } = require('../utils/settingsCache'); + + try { + // Prefer values from the request body so admins can test before saving. + // Fall back to cache → env vars. + const host = req.body.host || getSettingSync('smtp_host', process.env.SMTP_HOST || process.env.EMAIL_HOST || ''); + const port = req.body.port || getSettingSync('smtp_port', process.env.SMTP_PORT || process.env.EMAIL_PORT || '587'); + const secure = req.body.secure !== undefined + ? (req.body.secure === true || req.body.secure === 'true') + : (getSettingSync('smtp_secure', process.env.SMTP_SECURE || 'false').toLowerCase() === 'true'); + const user = req.body.user || getSettingSync('smtp_user', process.env.SMTP_USER || process.env.EMAIL_USER || ''); + // For pass: if a real value is supplied in body use it; if "••••••••" is sent, read from cache. + let pass = req.body.pass || ''; + if (!pass || pass === '••••••••') { + pass = getSettingSync('smtp_pass', process.env.SMTP_PASS || process.env.EMAIL_PASS || ''); + } else { + // Body supplied a plaintext password — decrypt it if it happens to be encrypted (shouldn't be, but guard anyway) + const { isEncrypted: _ie, decrypt: _d } = require('../utils/encryption'); + if (_ie(pass)) pass = _d(pass); + } + const from = req.body.from || getSettingSync('smtp_from', process.env.MAIL_FROM || process.env.EMAIL_FROM || ''); + + if (!host) { + res.status(400); throw new Error('SMTP host is not configured'); + } + + const transporter = nodemailer.createTransport({ + host, + port: parseInt(port, 10) || 587, + secure: secure || String(port) === '465', + auth: user && pass ? { user, pass } : undefined, + }); + + // verify() checks connectivity and authentication without sending a message + await transporter.verify(); + + // Send a real test email to the authenticated user so there's visible proof + const adminEmail = req.user?.email; + if (adminEmail) { + await transporter.sendMail({ + from: from || user || 'no-reply@hope-events.local', + to: adminEmail, + subject: 'SMTP test — Hope Events', + text: `This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`, + html: `

This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.

Host: ${host}:${port}
From: ${from || user}

`, + }); + } + + res.json({ message: `SMTP connection verified${adminEmail ? ` — a test email has been sent to ${adminEmail}` : ''}` }); + } catch (e) { + res.status(400).json({ message: friendlySmtpError(e), raw: e.message || String(e) }); + } +}; + +module.exports = { getSettings, getAllSettings, updateSettings, needsSetup, setupRegister, runSetup, testSmtp }; \ No newline at end of file diff --git a/backend/src/controllers/statsController.js b/backend/src/controllers/statsController.js new file mode 100644 index 0000000..a63d3cd --- /dev/null +++ b/backend/src/controllers/statsController.js @@ -0,0 +1,141 @@ +const prisma = require('../config/db'); +const { safeErrorMessage } = require('../utils/errorUtils'); + +// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard +// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders, +// computed with aggregate queries — never a full payments/events list shipped to the +// client just to be reduced down to a couple of numbers. + +async function computeScanStats(userId) { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + const whereBase = { scannedAt: { gte: startOfDay } }; + + const [totalToday, myToday, lastHour, byStaffRaw] = await Promise.all([ + prisma.ticketUsage.count({ where: whereBase }), + prisma.ticketUsage.count({ where: { ...whereBase, scannedById: userId } }), + prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } } }), + prisma.ticketUsage.groupBy({ by: ['scannedById'], where: whereBase, _count: { _all: true } }), + ]); + + const staffIds = byStaffRaw.map((b) => b.scannedById); + const staffUsers = staffIds.length > 0 + ? await prisma.user.findMany({ where: { id: { in: staffIds } }, select: { id: true, name: true } }) + : []; + const nameMap = Object.fromEntries(staffUsers.map((u) => [u.id, u.name])); + + return { + totalToday, + myToday, + lastHour, + byStaff: byStaffRaw.map((b) => ({ scannedById: b.scannedById, name: nameMap[b.scannedById] || 'Staff', count: b._count._all })), + }; +} + +function getRecentScans(limit = 10) { + return prisma.ticketUsage.findMany({ + orderBy: { scannedAt: 'desc' }, + take: limit, + select: { + id: true, + scannedAt: true, + quantityRedeemed: true, + scannedBy: { select: { id: true, name: true } }, + ticket: { + select: { + id: true, + event: { select: { title: true } }, + registrationOption: { select: { eventOption: { select: { name: true } } } }, + }, + }, + }, + }); +} + +function getActiveEventsCount() { + return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } }); +} + +async function computePaymentStats({ includeWeekMonth }) { + const startOfDay = new Date(new Date().setHours(0, 0, 0, 0)); + + const queries = [ + prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay } } }), + prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }), + ]; + if (includeWeekMonth) { + const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000); + const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000); + queries.push( + prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek } } }), + prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth } } }), + ); + } + + const [totalToday, donationsToday, totalWeek, totalMonth] = await Promise.all(queries); + + const stats = { + totalToday: totalToday._sum.amount || 0, + donationsToday, + }; + if (includeWeekMonth) { + stats.totalWeek = totalWeek._sum.amount || 0; + stats.totalMonth = totalMonth._sum.amount || 0; + } + return stats; +} + +// @desc All stats the staff dashboard needs, in one call +// @route GET /api/stats/staff +// @access Private/Staff+ +const getStaffDashboardStats = async (req, res) => { + try { + const [scanStats, recentScans] = await Promise.all([ + computeScanStats(req.user.id), + getRecentScans(10), + ]); + res.json({ scanStats, recentScans }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc All stats the supervisor dashboard needs, in one call +// @route GET /api/stats/supervisor +// @access Private/Supervisor+ +const getSupervisorDashboardStats = async (req, res) => { + try { + const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([ + computeScanStats(req.user.id), + getRecentScans(10), + computePaymentStats({ includeWeekMonth: false }), + getActiveEventsCount(), + ]); + res.json({ scanStats, recentScans, paymentStats, activeEventsCount }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc All stats the admin dashboard needs, in one call +// @route GET /api/stats/admin +// @access Private/Admin +const getAdminDashboardStats = async (req, res) => { + try { + const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([ + computeScanStats(req.user.id), + getRecentScans(10), + computePaymentStats({ includeWeekMonth: true }), + getActiveEventsCount(), + ]); + res.json({ scanStats, recentScans, paymentStats, activeEventsCount }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +module.exports = { + getStaffDashboardStats, + getSupervisorDashboardStats, + getAdminDashboardStats, +}; \ No newline at end of file diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js new file mode 100644 index 0000000..b020501 --- /dev/null +++ b/backend/src/controllers/ticketController.js @@ -0,0 +1,795 @@ +const prisma = require('../config/db'); +const { v4: uuidv4 } = require('uuid'); +const { safeErrorMessage } = require('../utils/errorUtils'); +const { generateTicketsForRegistration } = require('../utils/ticketUtils'); +const { assertRegistrationEventOpen } = require('../utils/cashupUtils'); + +// @desc Generate tickets for a registration +// @route POST /api/tickets/generate +// @access Private/Admin +const generateTickets = async (req, res) => { + try { + const { registrationId } = req.body; + + if (!registrationId) { + res.status(400); + throw new Error('registrationId is required'); + } + + await assertRegistrationEventOpen(registrationId, res); + + // Delegate fully to the shared utility which handles deduplication/consolidation + const newTickets = await generateTicketsForRegistration(registrationId); + + // Fetch the final canonical ticket set (one per option) + const options = await prisma.registrationOption.findMany({ where: { registrationId } }); + const tickets = await prisma.ticket.findMany({ + where: { registrationOptionId: { in: options.map(o => o.id) } }, + include: { + registrationOption: { include: { eventOption: true } }, + user: true, + event: true + }, + orderBy: { createdAt: 'asc' } + }); + + res.status(201).json({ + message: `${newTickets.length} ticket(s) generated, ${tickets.length} total`, + tickets + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get all tickets +// @route GET /api/tickets +// @access Private/Admin +const getTickets = async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100)); + const skip = (page - 1) * limit; + + const include = { + registrationOption: { + include: { + eventOption: true, + registration: { + include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } } } + } + } + }, + event: true, + user: { select: { id: true, name: true, email: true, phoneNumber: true } }, + usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } } + }; + + const [tickets, total] = await prisma.$transaction([ + prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }), + prisma.ticket.count() + ]); + + res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get user tickets +// @route GET /api/tickets/mytickets +// @access Private +const getUserTickets = async (req, res) => { + try { + const tickets = await prisma.ticket.findMany({ + where: { + userId: req.user.id, + registrationOption: { registration: { status: { not: 'cancelled' } } } + }, + include: { + registrationOption: { + include: { + eventOption: true, + variant: true, + registration: true + } + }, + event: true, + usages: { + include: { + scannedBy: { + select: { + id: true, + name: true, + email: true + } + } + } + } + } + }); + + res.json(tickets); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get ticket by ID +// @route GET /api/tickets/:id +// @access Private +const getTicketById = async (req, res) => { + try { + const ticket = await prisma.ticket.findUnique({ + where: { id: req.params.id }, + include: { + registrationOption: { + include: { + eventOption: true, + registration: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true + } + } + } + } + } + }, + event: true, + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true + } + }, + usages: { + include: { + scannedBy: { + select: { + id: true, + name: true, + email: true + } + } + } + } + } + }); + + if (!ticket) { + res.status(404); + throw new Error('Ticket not found'); + } + + // Check if user is authorized to view this ticket + if (ticket.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { + res.status(403); + throw new Error('Not authorized to view this ticket'); + } + + res.json(ticket); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get ticket by QR code +// @route GET /api/tickets/qr/:qrCode +// @access Private/Staff +const getTicketByQrCode = async (req, res) => { + try { + const ticket = await prisma.ticket.findUnique({ + where: { qrCode: req.params.qrCode }, + include: { + registrationOption: { + include: { + eventOption: true, + registration: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true + } + } + } + } + } + }, + event: true, + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true + } + }, + usages: { + include: { + scannedBy: { + select: { + id: true, + name: true, + email: true + } + } + } + } + } + }); + + if (!ticket) { + res.status(404); + throw new Error('Ticket not found'); + } + + res.json(ticket); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Lightweight ticket preview for the scan-confirm flow (only fields the confirm modal needs) +// @route GET /api/tickets/scan-preview/:qrCode +// @access Private/Staff +const getScanPreview = async (req, res) => { + try { + // Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs) + const rows = await prisma.$queryRaw` + SELECT + t.id, + t."qrCode", + t.quantity, + t."isUsed", + t."eventId", + ro.id AS "registrationOptionId", + eo.id AS "eventOptionId", + eo.name AS "eventOptionName", + ov.id AS "variantId", + ov.name AS "variantName", + e.id AS "evId", + e.title AS "eventTitle", + u.id AS "userId", + u.name AS "userName", + COALESCE( + json_agg( + json_build_object( + 'quantityRedeemed', tu."quantityRedeemed", + 'scannedAt', tu."scannedAt" + ) + ) FILTER (WHERE tu.id IS NOT NULL), + '[]'::json + ) AS usages + FROM "Ticket" t + LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId" + LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId" + LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId" + LEFT JOIN "Event" e ON e.id = t."eventId" + LEFT JOIN "User" u ON u.id = t."userId" + LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id + WHERE t."qrCode" = ${req.params.qrCode} + GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id + `; + + if (!rows || rows.length === 0) { + res.status(404); + throw new Error('Ticket not found'); + } + + const r = rows[0]; + res.json({ + id: r.id, + quantity: Number(r.quantity), + isUsed: r.isUsed, + eventId: r.eventId, + registrationOption: { + id: r.registrationOptionId, + eventOption: { id: r.eventOptionId, name: r.eventOptionName }, + variant: r.variantId ? { id: r.variantId, name: r.variantName } : null, + }, + event: { id: r.evId, title: r.eventTitle }, + user: { id: r.userId, name: r.userName }, + usages: r.usages || [] + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Scan ticket (mark as used, with optional partial quantity redemption) +// @route POST /api/tickets/scan/:qrCode +// @access Private/Staff +const scanTicket = async (req, res) => { + try { + // Single JOIN query — avoids multiple sequential round-trips to the remote DB + const rows = await prisma.$queryRaw` + SELECT + t.id, + t.quantity, + t."isUsed", + t."eventId", + e.title AS "eventTitle", + eo.id AS "eventOptionId", + eo.name AS "eventOptionName", + COALESCE( + json_agg( + json_build_object( + 'id', tu.id, + 'quantityRedeemed', tu."quantityRedeemed", + 'scannedAt', tu."scannedAt" + ) + ) FILTER (WHERE tu.id IS NOT NULL), + '[]'::json + ) AS usages + FROM "Ticket" t + LEFT JOIN "Event" e ON e.id = t."eventId" + LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId" + LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId" + LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id + WHERE t."qrCode" = ${req.params.qrCode} + GROUP BY t.id, e.id, eo.id + `; + + if (!rows || rows.length === 0) { + res.status(404); + throw new Error('Ticket not found'); + } + + const r = rows[0]; + const ticket = { + id: r.id, + quantity: Number(r.quantity), + isUsed: r.isUsed, + eventId: r.eventId, + event: { title: r.eventTitle }, + registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } }, + usages: r.usages || [] + }; + + // Optional server-side guard: ensure ticket belongs to the requested event when provided + const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim(); + if (providedEventId && ticket.eventId !== providedEventId) { + return res.status(400).json({ message: 'Ticket belongs to a different event', ticket }); + } + + // Compute how many have already been redeemed + const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0); + const remaining = (ticket.quantity || 1) - totalRedeemed; + + if (remaining <= 0) { + return res.status(403).json({ + message: 'Ticket has already been fully used', + ticket, + remaining: 0 + }); + } + + // Determine how many to redeem this scan (defaults to all remaining) + const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining; + const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining); + + const newTotalRedeemed = totalRedeemed + qtyToRedeem; + const newRemaining = (ticket.quantity || 1) - newTotalRedeemed; + const fullyUsed = newRemaining <= 0; + + // Run the usage insert and ticket status update in parallel — they don't depend on each other + const [ticketUsage] = await Promise.all([ + prisma.ticketUsage.create({ + data: { + id: uuidv4(), + ticketId: ticket.id, + scannedById: req.user.id, + quantityRedeemed: qtyToRedeem, + } + }), + prisma.ticket.update({ + where: { id: ticket.id }, + data: { isUsed: fullyUsed, updatedAt: new Date() } + }) + ]); + + res.json({ + message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`, + ticketUsage, + ticket, + qtyRedeemed: qtyToRedeem, + totalRedeemed: newTotalRedeemed, + remaining: newRemaining, + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get tickets by event +// @route GET /api/tickets/event/:eventId +// @access Private/Staff +const getTicketsByEvent = async (req, res) => { + try { + const limit = Math.min(1000, Math.max(1, parseInt(req.query.limit) || 1000)); + + const tickets = await prisma.ticket.findMany({ + where: { eventId: req.params.eventId }, + include: { + registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } }, + user: { select: { id: true, name: true, email: true, phoneNumber: true } }, + usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } } + }, + orderBy: { createdAt: 'desc' }, + take: limit + }); + + res.json(tickets); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Mark tickets as email sent +// @route PUT /api/tickets/email-sent +// @access Private/Admin +const markTicketsAsEmailSent = async (req, res) => { + try { + const { ticketIds } = req.body; + + if (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0) { + res.status(400); + throw new Error('Ticket IDs are required'); + } + + // Update tickets + await prisma.ticket.updateMany({ + where: { + id: { + in: ticketIds + } + }, + data: { + emailSent: true, + updatedAt: new Date() + } + }); + + res.json({ message: `${ticketIds.length} tickets marked as email sent` }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Email (and WhatsApp if preference set) tickets to user +// @route POST /api/tickets/email +// @access Private +// Optional body param: channel ('email'|'whatsapp'|'both') — overrides user's notification preference for this request +const emailTickets = async (req, res) => { + try { + // If caller specifies an explicit channel, build a minimal userOverride that forces that preference + const { channel } = req.body || {}; + let userOverride = null; + if (channel && ['email', 'whatsapp', 'both'].includes(channel)) { + // Load base user data then override the preference + const userId = req.user?.id; + if (userId) { + const baseUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true } + }); + if (baseUser) { + userOverride = { ...baseUser, notificationPreference: channel }; + } + } + } + return await emailTicketsInternal(req, res, userOverride); + } catch (error) { + console.error('Error emailing tickets:', error); + res.status(error.statusCode || 400).json({ message: error.message }); + } +}; + +// @desc Get recent ticket scans +// @route GET /api/tickets/scans/recent +// @access Private/Staff +const getRecentScans = async (req, res) => { + try { + const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100)); + const eventId = req.query.eventId || undefined; + + const scans = await prisma.ticketUsage.findMany({ + where: eventId ? { ticket: { eventId } } : undefined, + orderBy: { scannedAt: 'desc' }, + take: limit, + select: { + id: true, + scannedAt: true, + quantityRedeemed: true, + scannedBy: { select: { id: true, name: true } }, + ticket: { + select: { + id: true, + event: { select: { title: true } }, + registrationOption: { select: { eventOption: { select: { name: true } } } } + } + } + } + }); + + res.json(scans); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get scan statistics +// @route GET /api/tickets/scans/stats +// @access Private/Staff +const getScanStats = async (req, res) => { + try { + const eventId = req.query.eventId || undefined; + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + + // Total scans today (optionally by event) + const whereBase = { + scannedAt: { gte: startOfDay }, + ...(eventId ? { ticket: { eventId } } : {}) + }; + + const [totalToday, myToday, lastHour] = await Promise.all([ + prisma.ticketUsage.count({ where: whereBase }), + prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }), + prisma.ticketUsage.count({ + where: { + ...(eventId ? { ticket: { eventId } } : {}), + scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } + } + }) + ]); + + // Per-scanner breakdown today + const byStaff = await prisma.ticketUsage.groupBy({ + by: ['scannedById'], + where: whereBase, + _count: { _all: true } + }); + const staffIds = byStaff.map(b => b.scannedById); + const staffUsers = staffIds.length > 0 ? await prisma.user.findMany({ + where: { id: { in: staffIds } }, + select: { id: true, name: true } + }) : []; + const nameMap = Object.fromEntries(staffUsers.map(u => [u.id, u.name])); + + res.json({ + totalToday, + myToday, + lastHour, + byStaff: byStaff.map(b => ({ scannedById: b.scannedById, name: nameMap[b.scannedById] || 'Staff', count: b._count._all })) + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Send tickets to a specific phone/email (staff override for at-the-door) +// @route POST /api/tickets/send-to +// @access Private/Staff +const sendTicketsTo = async (req, res) => { + try { + const { registrationId, ticketIds, channel, overridePhone, overrideEmail } = req.body; + if (!registrationId && (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0)) { + res.status(400); throw new Error('registrationId or ticketIds required'); + } + if (!channel || !['email','whatsapp','both'].includes(channel)) { + res.status(400); throw new Error('channel must be email, whatsapp, or both'); + } + + // Determine which user owns the tickets to get their default contact info + let ownerUserId; + if (registrationId) { + const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true } }); + if (!reg) { res.status(404); throw new Error('Registration not found'); } + ownerUserId = reg.userId; + } else { + const firstTicket = await prisma.ticket.findUnique({ where: { id: ticketIds[0] }, select: { userId: true } }); + if (!firstTicket) { res.status(404); throw new Error('Ticket not found'); } + ownerUserId = firstTicket.userId; + } + + // Use a mock req that targets the ticket owner; override contact details in user object via closure + const originalUser = await prisma.user.findUnique({ + where: { id: ownerUserId }, + select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true } + }); + if (!originalUser) { res.status(404); throw new Error('Ticket owner not found'); } + + // Build a virtual user with override contact details + const virtualUser = { + ...originalUser, + email: overrideEmail || originalUser.email, + phoneNumber: overridePhone || originalUser.phoneNumber, + // Force the preference to match the requested channel + notificationPreference: channel, + }; + + // Reuse emailTickets logic via mock request but with virtual user + // We build a minimal mock and call the internal flow directly + const mockBody = registrationId ? { registrationId } : { ticketIds }; + const mockReq = { user: { id: ownerUserId }, body: mockBody, _virtualUser: virtualUser }; + const mockRes = { status: () => mockRes, json: (body) => { res.json(body); } }; + + // Call emailTickets with the virtual user override + await emailTicketsInternal(mockReq, mockRes, virtualUser); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// Internal helper used by both emailTickets and sendTicketsTo +async function emailTicketsInternal(req, res, userOverride) { + const { ticketIds, registrationId } = req.body; + const userId = req.user.id; + + const user = userOverride || await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, name: true, phoneNumber: true, notificationPreference: true } + }); + if (!user) { res.status(404); throw new Error('User not found'); } + const pref = user.notificationPreference || 'email'; + const noValidEmail = !user.email || user.email.endsWith('@guest.local'); + // For users without a valid email, WhatsApp is used as fallback regardless of preference + const canSendWA = !!user.phoneNumber && ((pref === 'whatsapp' || pref === 'both') || noValidEmail); + // Only block if neither channel can deliver + if (noValidEmail && !canSendWA) { + return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' }); + } + + let tickets; + if (registrationId) { + const registration = await prisma.registration.findUnique({ + where: { id: registrationId, userId } + }); + if (!registration) { res.status(404); throw new Error('Registration not found or does not belong to you'); } + const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } }); + tickets = await prisma.ticket.findMany({ + where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId }, + include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true }, + orderBy: { createdAt: 'asc' } + }); + } else { + tickets = await prisma.ticket.findMany({ + where: { id: { in: ticketIds }, userId }, + include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true }, + orderBy: { createdAt: 'asc' } + }); + } + + // Dedup by registrationOptionId + { + const seen = new Map(); const deduped = []; + for (const t of tickets) { + const key = t.registrationOptionId; + if (!seen.has(key)) { seen.set(key, t); deduped.push(t); } + else { + const existing = seen.get(key); + if ((existing.usages||[]).length === 0 && (t.usages||[]).length > 0) { + seen.set(key, t); deduped[deduped.indexOf(existing)] = t; + } + } + } + tickets = deduped; + } + if (tickets.length === 0) { res.status(404); throw new Error('No valid tickets found'); } + + // Generate PDF + const PDFDocument = require('pdfkit'); + const QRCode = require('qrcode'); + const fs = require('fs'); + const path = require('path'); + const tempFilePath = path.join(__dirname, '..', '..', 'temp', `tickets-${userId}-${Date.now()}.pdf`); + const tempDir = path.join(__dirname, '..', '..', 'temp'); + if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); + + const doc = new PDFDocument({ size: 'A4', margin: 20 }); + const writeStream = fs.createWriteStream(tempFilePath); + doc.pipe(writeStream); + const pageWidth = doc.page.width - 40; const pageHeight = doc.page.height - 40; + const ticketsPerRow = 2; const ticketsPerColumn = 4; + const ticketWidth = pageWidth / ticketsPerRow; const ticketHeight = pageHeight / ticketsPerColumn; + const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => { + QRCode.toDataURL(ticket.qrCode, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url })); + }))); + const qrCodeMap = qrCodes.reduce((map, item) => { map[item.ticketId] = item.qrDataUrl; return map; }, {}); + let ticketIndex = 0; + for (const ticket of tickets) { + const row = Math.floor(ticketIndex % ticketsPerColumn); + const col = Math.floor((ticketIndex / ticketsPerColumn) % ticketsPerRow); + const x = col * ticketWidth + 20; const y = row * ticketHeight + 20; + doc.rect(x, y, ticketWidth, ticketHeight).stroke(); + doc.font('Helvetica-Bold').fontSize(12).text(ticket.event.title, x + 10, y + 10, { width: ticketWidth - 20 }); + doc.font('Helvetica').fontSize(10); + const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : ''; + doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 }); + doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 }); + const eventDate = new Date(ticket.event.startDate); + doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 }); + doc.text(`Purchased by: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 }); + const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45; + const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90; + doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize }); + doc.fontSize(8).text(`Ticket ID: ${ticket.id}`, x + 10, qrY + qrCodeSize + 5, { width: ticketWidth - 20, align: 'center' }); + ticketIndex++; + if (ticketIndex % (ticketsPerRow * ticketsPerColumn) === 0 && ticketIndex < tickets.length) doc.addPage(); + } + doc.end(); + await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); }); + + const eventTitle = tickets[0].event.title; + const eventDate = tickets[0].event.startDate + ? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) + : ''; + const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`; + const sentChannels = []; + + const { sendMail, emailWrapper } = require('../utils/email'); + const { canWhatsApp, waPdf } = require('../utils/notify'); + const { buildWATicketCaption } = require('../utils/waMessages'); + const orgUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); + const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`; + const ticketHtml = emailWrapper(` +

🎟️ Your tickets are here!

+

Hi ${user.name},

+

Your tickets for ${eventTitle}${eventDate ? ` on ${eventDate}` : ''} are attached.

+

Questions? Visit ${orgUrl}.

+ `, { preheader: `Your tickets for ${eventTitle} are attached!` }); + + // Send email unless preference is whatsapp-only + if (pref !== 'whatsapp' && user.email && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@guest.local')) { + await sendMail({ + to: user.email, + subject: `Your tickets for ${eventTitle}`, + html: ticketHtml, + text: `Hi ${user.name},\n\nYour tickets for ${eventTitle}${eventDate ? ' on ' + eventDate : ''} are attached.\n\nSee you there!`, + attachments: [{ filename: pdfFilename, path: tempFilePath, contentType: 'application/pdf' }], + }); + sentChannels.push('email'); + } + + // Send WhatsApp if preference includes it, or as fallback when email isn't available + if (pref === 'whatsapp' || pref === 'both' || noValidEmail) { + const { normalizeZAPhone } = require('../utils/whatsapp'); + const normalizedPhone = normalizeZAPhone(user.phoneNumber); + if (normalizedPhone) { + const caption = buildWATicketCaption({ name: user.name, eventTitle, eventDate }); + await waPdf({ ...user, phoneNumber: normalizedPhone, notificationPreference: 'both' }, tempFilePath, pdfFilename, caption).catch(() => {}); + sentChannels.push('whatsapp'); + } + } + + try { require('fs').unlinkSync(tempFilePath); } catch {} + await prisma.ticket.updateMany({ where: { id: { in: tickets.map(t => t.id) } }, data: { emailSent: true, updatedAt: new Date() } }); + res.json({ message: `${tickets.length} ticket(s) sent via ${sentChannels.join(' & ') || 'no channel'}`, ticketIds: tickets.map(t => t.id) }); +} + +module.exports = { + generateTickets, + getTickets, + getUserTickets, + getTicketById, + getTicketByQrCode, + getScanPreview, + scanTicket, + getTicketsByEvent, + markTicketsAsEmailSent, + emailTickets, + sendTicketsTo, + getRecentScans, + getScanStats +}; \ No newline at end of file diff --git a/backend/src/controllers/uploadController.js b/backend/src/controllers/uploadController.js new file mode 100644 index 0000000..31f6f7c --- /dev/null +++ b/backend/src/controllers/uploadController.js @@ -0,0 +1,103 @@ +const path = require('path'); +const fs = require('fs'); +const multer = require('multer'); + +// Setup multer storage +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + const uploadPath = path.join(__dirname, '..', '..','public', 'uploads', 'events'); + + // Check if the upload location exists + try { + if (!fs.existsSync(uploadPath)) { + console.log(`Upload directory does not exist. Creating: ${uploadPath}`); + fs.mkdirSync(uploadPath, { recursive: true }); + } else { + // Verify we have write permissions to the directory + fs.accessSync(uploadPath, fs.constants.W_OK); + console.log(`Upload directory exists and is writable: ${uploadPath}`); + } + cb(null, uploadPath); + } catch (error) { + console.error(`Error with upload directory: ${error.message}`); + cb(new Error(`Cannot access upload directory: ${error.message}`)); + } + }, + filename: function (req, file, cb) { + const uniqueName = `${Date.now()}-${file.originalname}`; + cb(null, uniqueName); + } +}); + +// Multer middleware +const upload = multer({ + storage, + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max + fileFilter: function (req, file, cb) { + const ext = path.extname(file.originalname).toLowerCase(); + if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext)) { + return cb(new Error('Only images are allowed'), false); + } + cb(null, true); + } +}); + +// Logo storage (separate subfolder) +const logoStorage = multer.diskStorage({ + destination: function (req, file, cb) { + const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding'); + try { + if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true }); + cb(null, uploadPath); + } catch (error) { + cb(new Error(`Cannot access upload directory: ${error.message}`)); + } + }, + filename: function (req, file, cb) { + cb(null, `logo-${Date.now()}${path.extname(file.originalname).toLowerCase()}`); + } +}); + +const uploadLogo = multer({ + storage: logoStorage, + limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB + fileFilter: function (req, file, cb) { + const ext = path.extname(file.originalname).toLowerCase(); + if (!['.jpg', '.jpeg', '.png', '.webp', '.svg'].includes(ext)) { + return cb(new Error('Only image files are allowed'), false); + } + cb(null, true); + } +}); + +// Controller function +const uploadEventImage = (req, res) => { + // Check for multer errors which would be passed in req.multerError + if (req.multerError) { + return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` }); + } + + if (!req.file) { + return res.status(400).json({ message: 'No file uploaded' }); + } + + const imageUrl = `/uploads/events/${req.file.filename}`; + res.status(200).json({ url: imageUrl }); +}; + +const uploadLogoImage = (req, res) => { + if (req.multerError) { + return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` }); + } + if (!req.file) { + return res.status(400).json({ message: 'No file uploaded' }); + } + res.status(200).json({ url: `/uploads/branding/${req.file.filename}` }); +}; + +module.exports = { + upload, + uploadEventImage, + uploadLogo, + uploadLogoImage, +}; diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js new file mode 100644 index 0000000..a4dc3e3 --- /dev/null +++ b/backend/src/controllers/userController.js @@ -0,0 +1,934 @@ +const prisma = require('../config/db'); +const { generateToken, hashPassword, comparePassword } = require('../config/auth'); +const { v4: uuidv4 } = require('uuid'); +const { safeErrorMessage } = require('../utils/errorUtils'); +const axios = require('axios'); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +// Resolve a client IP from the request (works behind proxies) +function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) return forwarded.split(',')[0].trim(); + return req.socket?.remoteAddress || 'unknown'; +} + +const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/; + +// Fire-and-forget: send a login notification email with approximate geo location +async function sendLoginNotification(user, req) { + try { + const ip = getClientIp(req); + const userAgent = req.headers['user-agent'] || 'Unknown device'; + const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' }); + + let location = 'Unknown location'; + if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) { + try { + const geo = await axios.get( + `http://ip-api.com/json/${ip}?fields=status,city,regionName,country`, + { timeout: 3000 } + ); + if (geo.data?.status === 'success') { + const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean); + if (parts.length) location = parts.join(', '); + } + } catch { /* geo lookup failure is non-fatal */ } + } + + const { sendMail, buildLoginNotificationEmail } = require('../utils/email'); + const { buildWALogin } = require('../utils/waMessages'); + const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent }); + // Security: always email; also WhatsApp if preferred + await sendMail({ to: user.email, subject: 'New login to your Hope Events account', ...content }); + const { waText } = require('../utils/notify'); + await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {}); + } catch (e) { + console.warn('[login notification] Failed:', e?.message || e); + } +} + +// Fire-and-forget: send welcome email with next upcoming events +async function sendWelcomeEmail(user) { + try { + const events = await prisma.event.findMany({ + where: { isActive: true, startDate: { gt: new Date() } }, + orderBy: { startDate: 'asc' }, + take: 3, + select: { title: true, startDate: true }, + }); + const { sendMail, buildWelcomeEmail } = require('../utils/email'); + const { buildWAWelcome } = require('../utils/waMessages'); + const content = buildWelcomeEmail({ name: user.name, events }); + const { shouldEmail, waText } = require('../utils/notify'); + // Welcome is always sent via email; also via WhatsApp if preferred + await sendMail({ to: user.email, subject: 'Welcome to Hope Events!', ...content }); + await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {}); + } catch (e) { + console.warn('[welcome email] Failed:', e?.message || e); + } +} + +// @desc Register a new user +// @route POST /api/users +// @access Public +const registerUser = async (req, res) => { + try { + const { name, email, password, phoneNumber, notificationPreference } = req.body; + + // Normalize phone using SA-aware normalisation + const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp'); + const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null); + + // Validate preference — WhatsApp requires a valid phone number + const allowedPrefs = ['email', 'whatsapp', 'both']; + let pref = allowedPrefs.includes(notificationPreference) ? notificationPreference : 'email'; + if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) { + pref = 'email'; // silently fall back if no valid number + } + + // Check if user already exists + const userExists = await prisma.user.findFirst({ + where: { + OR: [ + { email }, + ...(phone ? [{ phoneNumber: phone }] : []) + ] + } + }); + + if (userExists) { + res.status(400); + throw new Error('User already exists'); + } + + // Hash password + const hashedPassword = await hashPassword(password); + + // Create user + const user = await prisma.user.create({ + data: { + id: uuidv4(), + name, + email, + password: hashedPassword, + phoneNumber: phone, + notificationPreference: pref, + updatedAt: new Date() + } + }); + + if (user) { + // Send welcome email in the background — don't block the response + sendWelcomeEmail(user).catch(() => {}); + + res.status(201).json({ + id: user.id, + name: user.name, + email: user.email, + role: user.role, + token: generateToken(user.id, user.role, user.tokenVersion) + }); + } else { + res.status(400); + throw new Error('Invalid user data'); + } + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Auth user & get token +// @route POST /api/users/login +// @access Public +const loginUser = async (req, res) => { + try { + const {email, password} = req.body; + + const rawInput = email; + + // Normalize phone input — convert SA local format (0xx) → international (27xx) + const { normalizeZAPhone } = require('../utils/whatsapp'); + const normalizedPhone = normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, ''); + + // Check for user email or phone + const user = await prisma.user.findFirst({ + where: { + OR: [ + { email: rawInput }, + ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : []), + ] + } + }); + + if (!user) { + res.status(401); + throw new Error('User does not exist'); + } + + // Check if user is active + if (!user.isActive) { + // If the account has a real email (not a guest placeholder), send an activation link via email + if (user.email && !user.email.endsWith('@guest.local')) { + try { + const token = uuidv4(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h + await prisma.passwordReset.updateMany({ + where: { userId: user.id, used: false }, + data: { used: true } + }); + await prisma.passwordReset.create({ + data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false } + }); + const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; + const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`; + const { sendMail, buildAccountActivationEmail } = require('../utils/email'); + const content = buildAccountActivationEmail({ name: user.name, activationUrl }); + sendMail({ to: user.email, subject: 'Activate your Hope Events account', ...content }) + .catch(e => console.warn('[activation email] Failed:', e?.message || e)); + } catch (e) { + console.warn('[activation token] Failed to create activation token:', e?.message || e); + } + res.status(401); + throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.'); + } + // No real email — if they have a phone number, send the activation link via WhatsApp + if (user.phoneNumber) { + try { + const token = uuidv4(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h + await prisma.passwordReset.updateMany({ + where: { userId: user.id, used: false }, + data: { used: true } + }); + await prisma.passwordReset.create({ + data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false } + }); + const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; + const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`; + const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'); + const waMessage = [ + `🔓 *Activate your ${orgName} account*`, + '', + `Hi ${user.name || 'there'},`, + '', + `Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`, + '', + activationUrl, + '', + `_This link expires in 24 hours._`, + ].join('\n'); + const { waTextAny } = require('../utils/notify'); + waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e)); + } catch (e) { + console.warn('[activation token WA] Failed to create activation token:', e?.message || e); + } + res.status(401); + throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.'); + } + res.status(401); + throw new Error('Your account has been deactivated'); + } + + const MAX_ATTEMPTS = 5; + const LOCK_TIME = 5 * 60 * 1000; // 5 min + + // Check lock + if (user.lockUntil && user.lockUntil > new Date()) { + const now = new Date().getTime(); + const lockTime = new Date(user.lockUntil).getTime(); + + const diffMs = lockTime - now; + const diffMinutes = Math.ceil(diffMs / (1000 * 60)); + + throw new Error(`Too many attempts. Try again in ${diffMinutes} minute(s).`); + } + + // Check password + const isMatch = await comparePassword(password, user.password); + + if (!isMatch) { + const attempts = user.failedAttempts + 1; + + await prisma.user.update({ + where: { id: user.id }, + data: { + failedAttempts: attempts, + lockUntil: + attempts >= MAX_ATTEMPTS + ? new Date(Date.now() + LOCK_TIME) + : null, + }, + }); + + throw new Error("Invalid email or password"); + } + + // ✅ SUCCESS → reset attempts + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { failedAttempts: 0, lockUntil: null }, + select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true }, + }); + + // Send login notification in the background + sendLoginNotification(updated, req).catch(() => {}); + + res.json({ + id: updated.id, + name: updated.name, + email: updated.email, + role: updated.role, + token: generateToken(updated.id, updated.role, updated.tokenVersion) + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get user profile +// @route GET /api/users/profile +// @access Private +const getUserProfile = async (req, res) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + id: true, + name: true, + email: true, + role: true, + phoneNumber: true, + notificationPreference: true, + createdAt: true, + updatedAt: true, + isActive: true + } + }); + + if (user) { + res.json(user); + } else { + res.status(404); + throw new Error('User not found'); + } + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Update user profile +// @route PUT /api/users/profile +// @access Private +const updateUserProfile = async (req, res) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user.id } + }); + + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {}; + + // If a password change is requested, verify current password for safety + let newHashedPassword = undefined; + if (typeof password === 'string' && password.trim().length > 0) { + const newPw = password.trim(); + if (!currentPassword || typeof currentPassword !== 'string' || currentPassword.length === 0) { + res.status(400); + throw new Error('Current password is required to set a new password'); + } + const matches = await comparePassword(currentPassword, user.password); + if (!matches) { + res.status(400); + throw new Error('Current password is incorrect'); + } + if (newPw.length < 8) { + res.status(400); + throw new Error('New password must be at least 8 characters long'); + } + newHashedPassword = await hashPassword(newPw); + } + + // Normalize phone + const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp'); + let newPhone = user.phoneNumber; + if (phoneNumber !== undefined) { + newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null; + } + + // Validate notification preference — WhatsApp requires a valid SA phone number + const allowedPrefs = ['email', 'whatsapp', 'both']; + let newPref = user.notificationPreference; + if (notificationPreference !== undefined) { + newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference; + if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) { + newPref = 'email'; + } + } + + // Update user data + const updatedUser = await prisma.user.update({ + where: { id: req.user.id }, + data: { + name: name || user.name, + email: email || user.email, + password: newHashedPassword ? newHashedPassword : user.password, + phoneNumber: newPhone, + notificationPreference: newPref, + updatedAt: new Date() + }, + select: { + id: true, + name: true, + email: true, + role: true, + phoneNumber: true, + notificationPreference: true, + createdAt: true, + updatedAt: true, + isActive: true, + tokenVersion: true + } + }); + + // If the password was changed, send a security alert email (fire-and-forget) + if (newHashedPassword) { + const { sendMail, buildPasswordChangedEmail } = require('../utils/email'); + const { getSettingSync } = require('../utils/settingsCache'); + const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || ''); + const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail }); + sendMail({ to: updatedUser.email, subject: 'Your Hope Events password was changed', ...content }) + .catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e)); + const { waText } = require('../utils/notify'); + waText(updatedUser, content.text).catch(() => {}); + } + + res.json({ + ...updatedUser, + token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion) + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get all users +// @route GET /api/users +// @access Private/Admin +const getUsers = async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100)); + const skip = (page - 1) * limit; + + const select = { + id: true, name: true, email: true, role: true, + phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true + }; + + // Build filters + const where = {}; + if (req.query.isActive !== undefined) { + where.isActive = req.query.isActive === 'true'; + } + if (req.query.role) { + where.role = req.query.role; + } + if (req.query.search) { + const s = req.query.search.trim(); + where.OR = [ + { name: { contains: s, mode: 'insensitive' } }, + { email: { contains: s, mode: 'insensitive' } }, + { phoneNumber: { contains: s, mode: 'insensitive' } }, + ]; + } + + const [users, total] = await prisma.$transaction([ + prisma.user.findMany({ where, select, orderBy: { name: 'asc' }, skip, take: limit }), + prisma.user.count({ where }) + ]); + + res.json({ data: users, total, page, limit, pages: Math.ceil(total / limit) }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Check whether an account already exists for a given email and/or phone +// @route GET /api/users/check-exists +// @access Private/Supervisor +const checkUserExists = async (req, res) => { + try { + const email = typeof req.query.email === 'string' ? req.query.email.trim() : ''; + const rawPhone = typeof req.query.phone === 'string' ? req.query.phone.trim() : ''; + + const { normalizeZAPhone } = require('../utils/whatsapp'); + const phone = normalizeZAPhone(rawPhone) || (rawPhone ? rawPhone.replace(/\D/g, '') : ''); + const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null); + + const searchClauses = [ + ...(email ? [{ email }] : []), + ...(phone ? [{ phoneNumber: phone }] : []), + ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []), + ]; + + if (searchClauses.length === 0) { + return res.json({ exists: false }); + } + + const existingUser = await prisma.user.findFirst({ + where: { OR: searchClauses }, + select: { email: true, phoneNumber: true }, + }); + + res.json({ + exists: !!existingUser, + hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'), + hasPhone: !!existingUser?.phoneNumber, + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Get user by ID +// @route GET /api/users/:id +// @access Private/Admin +const getUserById = async (req, res) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.params.id }, + select: { + id: true, + name: true, + email: true, + role: true, + phoneNumber: true, + createdAt: true, + updatedAt: true, + isActive: true + } + }); + + if (user) { + res.json(user); + } else { + res.status(404); + throw new Error('User not found'); + } + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Update user +// @route PUT /api/users/:id +// @access Private/Admin +const updateUser = async (req, res) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.params.id } + }); + + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + const { name, email, role, isActive, phoneNumber, password } = req.body; + + // Prepare data update, allow admin to set a new password + const data = { + name: name || user.name, + email: email || user.email, + role: role || user.role, + isActive: isActive !== undefined ? isActive : user.isActive, + phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber, + updatedAt: new Date() + }; + + if (password && typeof password === 'string' && password.trim().length > 0) { + data.password = await hashPassword(password.trim()); + } + + const updatedUser = await prisma.user.update({ + where: { id: req.params.id }, + data, + select: { + id: true, + name: true, + email: true, + role: true, + phoneNumber: true, + createdAt: true, + updatedAt: true, + isActive: true + } + }); + + res.json(updatedUser); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Delete user +// @route DELETE /api/users/:id +// @access Private/Admin +const deleteUser = async (req, res) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.params.id } + }); + + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + // Instead of deleting, we deactivate the user + await prisma.user.update({ + where: { id: req.params.id }, + data: { + isActive: false, + updatedAt: new Date() + } + }); + + res.json({ message: 'User deactivated' }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Anonymize user data (GDPR / "right to be forgotten") +// @route POST /api/users/:id/anonymize +// @access Private/Admin +const anonymizeUser = async (req, res) => { + try { + const user = await prisma.user.findUnique({ where: { id: req.params.id } }); + if (!user) { res.status(404); throw new Error('User not found'); } + + await prisma.user.update({ + where: { id: req.params.id }, + data: { + name: 'Deleted User', + email: `deleted-${req.params.id}@deleted.local`, + phoneNumber: null, + isActive: false, + tokenVersion: { increment: 1 }, + updatedAt: new Date() + } + }); + + res.json({ message: 'User data deleted' }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Request password reset +// @route POST /api/users/forgot +// @access Public +const requestPasswordReset = async (req, res) => { + try { + const { email } = req.body; + if (!email || typeof email !== 'string') { + res.status(400); + throw new Error('Email is required'); + } + + const user = await prisma.user.findUnique({ where: { email } }); + + // Explicitly check existence as requested + if (!user) { + return res.status(404).json({ message: 'Email not found' }); + } + + // Create token valid for 1 hour + const token = uuidv4(); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + // If PasswordReset model isn't available (migration not run), return clear error + if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') { + console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.'); + return res.status(500).json({ message: 'Password reset is not available. Please contact support.' }); + } + + // Invalidate previous tokens (optional) + await prisma.passwordReset.updateMany({ + where: { userId: user.id, used: false, expiresAt: { gt: new Date() } }, + data: { used: true } + }); + + await prisma.passwordReset.create({ + data: { + id: uuidv4(), + userId: user.id, + token, + expiresAt, + used: false + } + }); + + const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; + const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`; + + const { sendMail, buildPasswordResetEmail } = require('../utils/email'); + const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl }); + // Security: always email + sendMail({ to: user.email, subject: 'Reset your password', ...emailContent }) + .catch(e => console.warn('[password reset email] Failed:', e?.message || e)); + // Also WhatsApp if preferred (security message — sent in addition to email) + const { waText } = require('../utils/notify'); + waText(user, emailContent.text).catch(() => {}); + + return res.json({ message: 'If that email exists, a password reset link has been sent.' }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Reset password using token +// @route POST /api/users/reset +// @access Public +const resetPassword = async (req, res) => { + try { + const { token, password } = req.body; + if (!token || !password) { + res.status(400); + throw new Error('Token and new password are required'); + } + + // If PasswordReset model isn't available, avoid crashing and return a generic error + if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function' || typeof prisma.passwordReset.update !== 'function') { + res.status(400); + throw new Error('Invalid or expired token'); + } + + const reset = await prisma.passwordReset.findUnique({ where: { token } }); + if (!reset || reset.used || reset.expiresAt < new Date()) { + res.status(400); + throw new Error('Invalid or expired token'); + } + + const user = await prisma.user.findUnique({ where: { id: reset.userId } }); + if (!user || !user.isActive) { + res.status(400); + throw new Error('User not found or inactive'); + } + + const newHashed = await hashPassword(password); + + await prisma.$transaction([ + prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }), + prisma.passwordReset.update({ where: { token }, data: { used: true } }) + ]); + + res.json({ message: 'Password has been reset successfully' }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Activate account using token (for inactive/guest accounts) +// @route POST /api/users/activate +// @access Public +const activateAccount = async (req, res) => { + try { + const { token, password } = req.body; + if (!token || !password) { + res.status(400); + throw new Error('Token and password are required'); + } + if (typeof password !== 'string' || password.trim().length < 8) { + res.status(400); + throw new Error('Password must be at least 8 characters'); + } + + if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function') { + res.status(400); + throw new Error('Invalid or expired token'); + } + + const reset = await prisma.passwordReset.findUnique({ where: { token } }); + if (!reset || reset.used || reset.expiresAt < new Date()) { + res.status(400); + throw new Error('Invalid or expired activation link'); + } + + const user = await prisma.user.findUnique({ where: { id: reset.userId } }); + if (!user) { + res.status(400); + throw new Error('User not found'); + } + + const newHashed = await hashPassword(password.trim()); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: user.id }, + data: { password: newHashed, isActive: true, updatedAt: new Date() } + }), + prisma.passwordReset.update({ where: { token }, data: { used: true } }) + ]); + + const updated = await prisma.user.findUnique({ + where: { id: user.id }, + select: { id: true, name: true, email: true, role: true, tokenVersion: true } + }); + + // Send welcome email (skip guest/placeholder accounts) + if (updated.email && !updated.email.endsWith('@guest.local')) { + sendWelcomeEmail(updated).catch(() => {}); + } + + res.json({ + message: 'Account activated successfully.', + id: updated.id, + name: updated.name, + email: updated.email, + role: updated.role, + token: generateToken(updated.id, updated.role, updated.tokenVersion) + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Revoke all active sessions for the logged-in user (increments tokenVersion) +// @route POST /api/users/revoke-sessions +// @access Private +const revokeMySession = async (req, res) => { + try { + const updated = await prisma.user.update({ + where: { id: req.user.id }, + data: { tokenVersion: { increment: 1 } }, + select: { id: true, role: true, tokenVersion: true }, + }); + // Bumping tokenVersion invalidates every existing token, including the one + // this request just used. Issue a fresh token for the current device so it + // isn't logged out too. + res.json({ + message: 'All other sessions have been signed out.', + token: generateToken(updated.id, updated.role, updated.tokenVersion), + }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Admin: revoke all sessions for a specific user +// @route POST /api/users/:id/revoke-sessions +// @access Private/Admin +const adminRevokeUserSessions = async (req, res) => { + try { + const target = await prisma.user.findUnique({ where: { id: req.params.id } }); + if (!target) { + res.status(404); + throw new Error('User not found'); + } + await prisma.user.update({ + where: { id: req.params.id }, + data: { tokenVersion: { increment: 1 } }, + }); + res.json({ message: `Sessions revoked for ${target.name}.` }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Close (and optionally anonymise) the logged-in user's own account +// @route POST /api/users/close-account +// @access Private +const closeAccount = async (req, res) => { + try { + const { deleteData = false, password } = req.body || {}; + + // Re-verify password before allowing account closure + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) { res.status(404); throw new Error('User not found'); } + + const passwordOk = await comparePassword(password, user.password); + if (!passwordOk) { + res.status(400); + throw new Error('Incorrect password'); + } + + // Capture real details before any anonymisation so the email goes to the right address + const realEmail = user.email; + const realName = user.name; + + if (deleteData) { + // Anonymise: wipe all personal data while keeping the row intact for referential integrity + await prisma.user.update({ + where: { id: req.user.id }, + data: { + name: 'Deleted User', + email: `deleted-${uuidv4()}@deleted.invalid`, + password: '', + phoneNumber: null, + isActive: false, + tokenVersion: { increment: 1 }, + updatedAt: new Date(), + }, + }); + // Send closure confirmation to the real address (before it was wiped) + if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) { + const { sendMail, buildAccountClosedEmail } = require('../utils/email'); + const { waText } = require('../utils/notify'); + const { buildWAAccountClosed } = require('../utils/waMessages'); + const content = buildAccountClosedEmail({ name: realName, dataDeleted: true }); + sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {}); + // WhatsApp while we still have phone (send before data wipe completes in-flight) + waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {}); + } + return res.json({ message: 'Your account and personal data have been removed.' }); + } else { + // Soft-deactivate only + await prisma.user.update({ + where: { id: req.user.id }, + data: { + isActive: false, + tokenVersion: { increment: 1 }, + updatedAt: new Date(), + }, + }); + // Send closure confirmation + if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) { + const { sendMail, buildAccountClosedEmail } = require('../utils/email'); + const { waText } = require('../utils/notify'); + const { buildWAAccountClosed } = require('../utils/waMessages'); + const content = buildAccountClosedEmail({ name: realName, dataDeleted: false }); + sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {}); + waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {}); + } + return res.json({ message: 'Your account has been deactivated.' }); + } + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +module.exports = { + registerUser, + loginUser, + getUserProfile, + updateUserProfile, + getUsers, + checkUserExists, + getUserById, + updateUser, + deleteUser, + anonymizeUser, + requestPasswordReset, + resetPassword, + activateAccount, + revokeMySession, + adminRevokeUserSessions, + closeAccount, +}; \ No newline at end of file diff --git a/backend/src/controllers/webhookController.js b/backend/src/controllers/webhookController.js new file mode 100644 index 0000000..83ae861 --- /dev/null +++ b/backend/src/controllers/webhookController.js @@ -0,0 +1,572 @@ +const crypto = require('crypto'); +const prisma = require('../config/db'); +const { v4: uuidv4 } = require('uuid'); +const { generateTicketsForRegistration } = require('../utils/ticketUtils'); +const { emailTickets } = require('./ticketController'); +const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing'); + +// @desc Handle Yoco webhook events +// @route POST /api/webhooks/yoco +// @access Public +const handleYocoWebhook = async (req, res) => { + try { + const headers = req.headers; + const requestBody = req.rawBody; + + // Log every JSON request body for webhook controller + try { + const contentType = headers['content-type'] || headers['Content-Type'] || ''; + let parsedBody = null; + if (typeof requestBody === 'string') { + try { + parsedBody = JSON.parse(requestBody); + } catch (e) { + // Not valid JSON; keep as raw string + } + } else if (requestBody && typeof requestBody === 'object') { + parsedBody = requestBody; + } + } catch (logErr) { + // Fail-safe: never block webhook processing due to logging issues + console.error('Failed to log webhook JSON request:', logErr); + } + + // Verify webhook signature + const id = headers['webhook-id']; + const timestamp = headers['webhook-timestamp']; + + const rawSigHeader = headers['webhook-signature']; + if (!id || !timestamp || !rawSigHeader) { + const responseObj = { + success: false, + message: 'Missing webhook headers', + details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) } + }; + return res.status(400).json(responseObj); + } + + // Reject replayed requests (Yoco recommends a 3-minute tolerance window) + const webhookTimeMs = parseInt(timestamp, 10) * 1000; + if (isNaN(webhookTimeMs) || Math.abs(Date.now() - webhookTimeMs) > 3 * 60 * 1000) { + return res.status(400).json({ success: false, message: 'Webhook timestamp outside acceptable window' }); + } + + // Construct the signed content + const signedContent = `${id}.${timestamp}.${requestBody}`; + + // Get the webhook secret from environment variables + const secret = process.env.YOCO_WEBHOOK_SECRET; + if (!secret) { + console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables'); + const responseObj = { + success: false, + message: 'Server configuration error', + details: { error: 'Missing webhook secret configuration' } + }; + return res.status(500).json(responseObj); + } + + const secretBytes = Buffer.from(secret.split('_')[1], "base64"); + + // Calculate expected signature + const expectedSignature = crypto + .createHmac('sha256', secretBytes) + .update(signedContent) + .digest('base64'); + + // Accept if any of the space-separated signatures match (supports key rotation) + const signatures = rawSigHeader.split(' ').map(s => s.split(',')[1]).filter(Boolean); + const signatureValid = signatures.some(sig => { + try { + return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(sig)); + } catch { + return false; + } + }); + + if (!signatureValid) { + console.error('Invalid webhook signature'); + const responseObj = { + success: false, + message: 'Invalid signature', + details: { error: 'Webhook signature verification failed' } + }; + return res.status(403).json(responseObj); + } + + // Parse the webhook payload + const webhookData = JSON.parse(requestBody); + + // Extract checkoutId from payload or metadata + const receivedCheckoutId = webhookData?.payload?.checkoutId || webhookData?.payload?.metadata?.checkoutId || webhookData?.payload?.metadata?.checkout_id; + + // Optional filtering: allow only specific checkoutIds or prefixes for this endpoint + try { + const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || ''; + const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || ''; + const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean); + const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean); + + const hasFilter = allowedIds.length > 0 || allowedPrefixes.length > 0; + if (hasFilter) { + const idMatches = receivedCheckoutId && ( + allowedIds.includes(receivedCheckoutId) || + allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref)) + ); + if (!idMatches) { + // Store event for audit/unreconciled tracking + try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store filtered yoco event:', e?.message || e); } + // Acknowledge but ignore processing for unrelated checkout flows + const responseObj = { + success: true, + message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint', + data: { + filtered: true, + receivedCheckoutId, + allowedIdsCount: allowedIds.length, + allowedPrefixesCount: allowedPrefixes.length + } + }; + return res.status(200).json(responseObj); + } + } + } catch (filterErr) { + // Never block webhook due to filter parsing errors; log only + console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr); + } + + // Automatic filtering by database registration record, with donation allowance + try { + if (receivedCheckoutId) { + const existingRegistration = await prisma.registration.findFirst({ + where: { checkoutId: receivedCheckoutId }, + select: { id: true } + }); + if (!existingRegistration) { + // Allow donations (no registration) to proceed based on metadata + const meta = webhookData?.payload?.metadata || {}; + const isDonation = meta?.type === 'donation' || !!meta?.isDonation || !!meta?.eventId; + if (!isDonation) { + try { + await saveYocoTransaction(webhookData); + } catch (saveErr) { + console.warn('Failed to save unreconciled transaction:', saveErr?.message || saveErr); + } + const responseObj = { + success: true, + message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId', + data: { + filtered: true, + reason: 'no_registration_for_checkoutId', + receivedCheckoutId + } + }; + return res.status(200).json(responseObj); + } + } + } + } catch (dbFilterErr) { + // Do not fail webhook due to DB filter issues + console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr); + } + + let processedData = null; + + // Process different event types + switch (webhookData.type) { + case 'payment.succeeded': + processedData = await handlePaymentSucceeded(webhookData); + break; + // Add more event types as needed + default: + console.log(`Unhandled webhook event type: ${webhookData.type}`); + try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store unhandled yoco event:', e?.message || e); } + const responseObj = { + success: true, + message: 'Webhook received but event type not handled', + data: { + eventType: webhookData.type, + webhookId: id, + timestamp: timestamp + } + }; + return res.status(200).json(responseObj); + } + + // Return detailed success response + const responseObj = { + success: true, + message: `Successfully processed ${webhookData.type} webhook`, + data: { + eventType: webhookData.type, + webhookId: id, + timestamp: timestamp, + processedData + } + }; + return res.status(200).json(responseObj); + } catch (error) { + console.error('Webhook error:', error); + const responseObj = { + success: false, + message: error.message, + details: { + error: error.stack, + timestamp: new Date().toISOString() + } + }; + return res.status(500).json(responseObj); + } +}; + +// Handle payment.succeeded event +const handlePaymentSucceeded = async (webhookData) => { + try { + const paymentData = webhookData.payload; + + // Log the payment data + console.log('Payment succeeded:', paymentData); + + // Extract payment details + const { + id: yocoPaymentId, + amount, + currency, + status, + metadata, + paymentMethodDetails: method, + checkoutId + } = paymentData; + + // Check if payment already exists to avoid duplicates + const existingPayment = await prisma.payment.findFirst({ + where: { + externalId: yocoPaymentId + } + }); + + if (existingPayment) { + console.log(`Payment ${yocoPaymentId} already processed`); + try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: existingPayment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction for existing payment:', e?.message || e); } + return { + status: 'already_processed', + paymentId: existingPayment.id, + externalId: yocoPaymentId + }; + } + + // Find the registration by checkoutId + let registration = null; + if (checkoutId) { + registration = await prisma.registration.findFirst({ + where: { + checkoutId: checkoutId + }, + include: { + event: { + select: { + id: true, + title: true + } + }, + user: { + select: { + id: true, + name: true, + email: true + } + } + } + }); + } + + // If no registration found by checkoutId, try metadata + if (!registration && metadata && metadata.registrationId) { + registration = await prisma.registration.findUnique({ + where: { + id: metadata.registrationId + }, + include: { + event: { + select: { + id: true, + title: true + } + }, + user: { + select: { + id: true, + name: true, + email: true + } + } + } + }); + } + + // Resolve userId: registration owner → metadata → first active admin (never use a non-existent UUID) + let resolvedUserId = registration?.userId || metadata?.userId || null; + if (!resolvedUserId) { + try { + const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } }); + resolvedUserId = adminUser?.id || null; + } catch (e) { /* ignore — payment will fail below if still null */ } + } + + // Create payment record + const payment = await prisma.payment.create({ + data: { + id: uuidv4(), // Generate a UUID for the payment + amount: amount / 100, // Convert cents to your currency unit + method: method.type, // The type of payment from the webhook + status: status === 'succeeded' ? 'completed' : status, + externalId: yocoPaymentId, + registrationId: registration?.id || null, + userId: resolvedUserId, + eventId: registration?.eventId || metadata?.eventId || null, + isDonation: !registration?.id + }, + include: { + user: { + select: { + id: true, + name: true, + email: true + } + }, + registration: registration ? { + include: { + event: true + } + } : undefined, + event: (registration?.eventId || metadata?.eventId) ? true : undefined + } + }); + + // If this payment is for a registration, update the registration status + let updatedRegistration = null; + let generatedTickets = []; + + if (registration) { + updatedRegistration = await updateRegistrationStatus(registration.id); + + // If registration status is 'paid', tickets were generated + if (updatedRegistration.status === 'paid') { + // Find the generated tickets + generatedTickets = await prisma.ticket.findMany({ + where: { + registrationOption: { + registrationId: registration.id + } + }, + select: { + id: true, + qrCode: true, + isUsed: true, + registrationOptionId: true, + eventId: true + } + }); + } + } + + console.log(`Payment ${yocoPaymentId} processed successfully`); + + // Mark the Yoco transaction as reconciled and link the payment + try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: payment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction after payment create:', e?.message || e); } + + // Send emails: payment confirmation first, then tickets (guarantees order) + const _whPaymentId = payment.id; + const _whUserId = registration?.userId; + const _whRegId = registration?.id; + const _whTicketsGenerated = generatedTickets.length > 0; + (async () => { + try { + const { sendPaymentEmails } = require('../utils/notifications'); + await sendPaymentEmails(_whPaymentId); + } catch (e) { + console.error('Failed to send webhook payment emails:', e); + } + if (_whTicketsGenerated && _whUserId && _whRegId) { + const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } }; + const mockRes = { status: () => mockRes, json: () => {} }; + try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); } + } + })(); + + // Return processed data + return { + status: 'success', + payment: { + id: payment.id, + externalId: yocoPaymentId, + amount: payment.amount, + method: payment.method, + status: payment.status, + createdAt: payment.createdAt + }, + registration: registration ? { + id: registration.id, + status: updatedRegistration ? updatedRegistration.status : registration.status, + eventId: registration.eventId, + eventTitle: registration.event?.title, + userId: registration.userId, + userName: registration.user?.name + } : null, + generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined + }; + } catch (error) { + console.error('Error processing payment.succeeded event:', error); + throw error; + } +}; + +// Update registration status based on payments +const updateRegistrationStatus = async (registrationId) => { + try { + // Refresh early-bird pricing before computing totalDue — ensures expired/exhausted tiers + // are accounted for so we never mark a registration paid based on stale prices. + try { await refreshPricingForRegistration(registrationId); } catch (e) { + console.warn('[webhook] Price refresh failed:', e?.message); + } + + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { + include: { + eventOption: { + include: { + earlyBirdTiers: true + } + } + } + }, + payments: true + } + }); + + if (!registration) { + throw new Error(`Registration ${registrationId} not found`); + } + + // Calculate total amount paid + const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0); + + // Calculate total amount due (uses priceSnapshot — refreshed above) + const totalDue = computeRegistrationTotalDue(registration, new Date()); + + // Update registration status based on payment + let newStatus; + if (totalPaid >= totalDue) { + newStatus = 'paid'; + } else if (totalPaid > 0) { + newStatus = 'partial_paid'; + } else { + newStatus = 'pending'; + } + + const updatedRegistration = await prisma.registration.update({ + where: { id: registrationId }, + data: { + status: newStatus, + updatedAt: new Date() + } + }); + + // Generate tickets if status is "paid" (email handled by caller after payment email) + let generatedTickets = []; + if (newStatus === 'paid') { + try { + generatedTickets = await generateTicketsForRegistration(registrationId); + } catch (error) { + console.error('Error generating tickets:', error); + // Don't throw the error, just log it - we still want to update the registration status + } + } + + // Return the updated registration + return { + ...updatedRegistration, + totalPaid, + totalDue, + generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined + }; + } catch (error) { + console.error('Error updating registration status:', error); + throw error; + } +}; + +const handleWhatsappWebhook = async (req, res) => { + try { + const { body } = req; + const { message } = body; + } catch (error) { + res.status(500).json({ error: 'Internal Server Error' }); + } +} + +module.exports = { + handleYocoWebhook, + handleWhatsappWebhook, +}; + +// Helper: save or update Yoco transactions for any webhook event +async function saveYocoTransaction(webhookData, updates = {}) { + try { + const payload = webhookData?.payload || {}; + const externalId = payload?.id || webhookData?.id; + if (!externalId) { + throw new Error('Missing external id in webhook payload'); + } + + const amount = typeof payload?.amount === 'number' ? payload.amount : null; // cents if provided + const currency = payload?.currency || null; + const createdDateStr = payload?.createdDate || webhookData?.createdDate; + const createdDate = createdDateStr ? new Date(createdDateStr) : null; + const checkoutId = payload?.checkoutId || payload?.metadata?.checkoutId || payload?.metadata?.checkout_id || null; + const methodType = payload?.paymentMethodDetails?.type || null; + + // Try find existing + const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } }); + if (existing) { + // Update with any provided updates + if (updates && Object.keys(updates).length > 0) { + return await prisma.yocoTransaction.update({ + where: { id: existing.id }, + data: { ...updates, updatedAt: new Date() } + }); + } + return existing; + } + + // Create new + const record = await prisma.yocoTransaction.create({ + data: { + externalId, + amount, + currency, + createdDate, + checkoutId, + methodType, + reconciled: false, + raw: webhookData, + ...updates + } + }); + return record; + } catch (e) { + // Unique constraint race: fetch existing + if (e?.code === 'P2002') { + const payload = webhookData?.payload || {}; + const externalId = payload?.id || webhookData?.id; + const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } }); + if (existing && updates && Object.keys(updates).length > 0) { + return await prisma.yocoTransaction.update({ where: { id: existing.id }, data: { ...updates, updatedAt: new Date() } }); + } + return existing; + } + throw e; + } +} \ No newline at end of file diff --git a/backend/src/controllers/whatsappBroadcastController.js b/backend/src/controllers/whatsappBroadcastController.js new file mode 100644 index 0000000..43151c4 --- /dev/null +++ b/backend/src/controllers/whatsappBroadcastController.js @@ -0,0 +1,170 @@ +const prisma = require('../config/db'); + +function fmtDate(d) { + try { return new Date(d).toLocaleString(); } catch { return String(d); } +} + +function getFrontendBaseUrl() { + return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); +} + +function replacePlaceholders(str, ctx) { + if (!str) return str; + return String(str) + .replace(/\{\{\s*name\s*\}\}/g, ctx.name || '') + .replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '') + .replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '') + .replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || ''); +} + +function parseFreeformPhones(lines) { + const recipients = []; + const input = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/); + for (const raw of input) { + const s = String(raw || '').trim(); + if (!s) continue; + // Format: Name or just phone + const m = s.match(/^(.*?)<\s*([+\d\s()-]+)\s*>\s*$/); + if (m) { + recipients.push({ phone: m[2].trim(), name: m[1].trim().replace(/^"|"$/g, '').trim() }); + } else { + // Accept raw phone-like strings + const digits = s.replace(/\D/g, ''); + if (digits.length >= 9) recipients.push({ phone: s, name: '' }); + } + } + return recipients; +} + +// @desc Preview WhatsApp broadcast recipients +// @route POST /api/whatsapp-broadcasts/preview +// @access Private/Supervisor or Admin +const previewWhatsAppBroadcast = async (req, res) => { + try { + const { userIds, phones, eventId } = req.body || {}; + const { normalizeZAPhone } = require('../utils/whatsapp'); + + const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : []; + let users = []; + if (ids.length) { + users = await prisma.user.findMany({ + where: { id: { in: ids } }, + select: { id: true, name: true, phoneNumber: true, notificationPreference: true } + }); + } + + const extra = parseFreeformPhones(phones); + + const map = new Map(); + for (const u of users) { + const phone = normalizeZAPhone(u.phoneNumber); + if (!phone) continue; + if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' }); + } + for (const r of extra) { + const phone = normalizeZAPhone(r.phone); + if (!phone) continue; + if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' }); + } + const recipients = Array.from(map.values()); + + return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20) }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Send WhatsApp broadcast now +// @route POST /api/whatsapp-broadcasts/send +// @access Private/Supervisor or Admin +const sendWhatsAppBroadcast = async (req, res) => { + try { + const { message, userIds, phones, eventId } = req.body || {}; + if (!message) { + return res.status(400).json({ message: 'message is required' }); + } + + const { normalizeZAPhone, sendText } = require('../utils/whatsapp'); + + const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : []; + let users = []; + if (ids.length) { + users = await prisma.user.findMany({ + where: { id: { in: ids } }, + select: { id: true, name: true, phoneNumber: true } + }); + } + const extra = parseFreeformPhones(phones); + + const map = new Map(); + for (const u of users) { + const phone = normalizeZAPhone(u.phoneNumber); + if (!phone) continue; + if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' }); + } + for (const r of extra) { + const phone = normalizeZAPhone(r.phone); + if (!phone) continue; + if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' }); + } + const recipients = Array.from(map.values()); + if (recipients.length === 0) { + return res.status(400).json({ message: 'No valid recipients with phone numbers' }); + } + + let event = null; + if (eventId && typeof eventId === 'string') { + event = await prisma.event.findUnique({ where: { id: eventId } }); + } + const eventTitle = event?.title || ''; + const eventStart = event?.startDate ? fmtDate(event.startDate) : ''; + const eventLink = event ? `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}` : ''; + + const results = await Promise.allSettled(recipients.map(async rcpt => { + const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink }; + const finalMessage = replacePlaceholders(message, ctx); + await sendText(rcpt.phone, finalMessage); + })); + + const sent = results.filter(r => r.status === 'fulfilled').length; + results.forEach((r, i) => { + if (r.status === 'rejected') { + try { console.warn('[wa-broadcast] failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {} + } + }); + + return res.json({ matched: recipients.length, sent }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +// @desc Schedule a WhatsApp broadcast +// @route POST /api/whatsapp-broadcasts/schedule +// @access Private/Supervisor or Admin +const scheduleWhatsAppBroadcast = async (req, res) => { + try { + const { scheduledAt, message, userIds, phones, eventId } = req.body || {}; + if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' }); + const when = new Date(scheduledAt); + if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' }); + if (!message) return res.status(400).json({ message: 'message is required' }); + + const payload = { message, userIds, phones, eventId }; + + const { addJob } = require('../utils/scheduledEmails'); + const created = addJob({ + broadcast: true, + channel: 'whatsapp', + scheduledAt: when.toISOString(), + createdById: req.user?.id || null, + payload, + }); + + return res.status(201).json({ message: 'WhatsApp broadcast scheduled', job: created }); + } catch (error) { + return res.status(400).json({ message: error.message }); + } +}; + +module.exports = { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast }; \ No newline at end of file diff --git a/backend/src/controllers/whatsappController.js b/backend/src/controllers/whatsappController.js new file mode 100644 index 0000000..0dee38e --- /dev/null +++ b/backend/src/controllers/whatsappController.js @@ -0,0 +1,224 @@ +const { + getConfig, setConfig, + getStatus, startSession, restartSession, logoutSession, + getQr, requestPairingCode, + createInstance, deleteInstance, +} = require('../utils/whatsapp'); +const { sendMail } = require('../utils/email'); + +// ─── Config management ──────────────────────────────────────────────────────── + +/** + * GET /api/whatsapp/config + * Returns the current WAWP credentials (token is masked). + */ +const getConfigHandler = async (req, res) => { + try { + const { token, instanceId } = await getConfig(); + // Mask the token for display — show first 4 chars + asterisks + const maskedToken = token + ? token.slice(0, 4) + '*'.repeat(Math.max(0, token.length - 4)) + : ''; + res.json({ + tokenMasked: maskedToken, + instanceId: instanceId || '', + hasToken: !!token, + hasInstance: !!instanceId, + configured: !!(token && instanceId), + }); + } catch (e) { + res.status(500).json({ message: e?.message || 'Failed to load config' }); + } +}; + +/** + * POST /api/whatsapp/config + * Body: { token, instanceId } + * Saves credentials to DB; instanceId is optional (keep existing if omitted). + */ +const saveConfigHandler = async (req, res) => { + try { + const { token, instanceId } = req.body || {}; + + const current = await getConfig(); + + // Resolve token: '_clear_' resets it; blank/absent keeps existing + let resolvedToken = current.token; + if (token === '_clear_') { + resolvedToken = ''; + } else if (token && token.trim()) { + resolvedToken = token.trim(); + } + + // Resolve instanceId: blank/absent keeps existing + const resolvedInstanceId = (instanceId && instanceId.trim()) + ? instanceId.trim() + : current.instanceId || ''; + + await setConfig(resolvedToken, resolvedInstanceId); + res.json({ message: 'WAWP configuration saved successfully.' }); + } catch (e) { + res.status(500).json({ message: e?.message || 'Failed to save config' }); + } +}; + +// ─── Instance lifecycle ─────────────────────────────────────────────────────── + +/** + * POST /api/whatsapp/create-instance + * Body: { name? } + * Creates a new WAWP session instance and saves its id to the DB. + */ +const createInstanceHandler = async (req, res) => { + try { + const { name } = req.body || {}; + const data = await createInstance(name); + res.json({ message: 'Instance created and saved.', ...data }); + } catch (e) { + res.status(500).json({ message: e?.response?.data?.message || e.message }); + } +}; + +/** + * POST /api/whatsapp/delete-instance + * Deletes the current WAWP instance and clears it from the DB. + */ +const deleteInstanceHandler = async (req, res) => { + try { + const data = await deleteInstance(); + res.json({ message: 'Instance deleted.', ...data }); + } catch (e) { + res.status(500).json({ message: e?.response?.data?.message || e.message }); + } +}; + +// ─── Admin session endpoints ────────────────────────────────────────────────── + +const getStatusHandler = async (req, res) => { + try { + res.json(await getStatus()); + } catch (e) { + res.status(500).json({ message: e?.response?.data?.message || e.message }); + } +}; + +const getQrHandler = async (req, res) => { + try { + const data = await getQr(); + // Normalise: strip leading "data:image/png;base64," if WAWP already includes it, + // so the client always receives a clean base64 string it can prefix itself. + if (data?.qr) { + data.qr = data.qr.replace(/^data:image\/png;base64,/, ''); + } + res.json(data); + } catch (e) { + res.status(500).json({ message: e?.response?.data?.message || e.message }); + } +}; + +const requestCodeHandler = async (req, res) => { + try { + const { phoneNumber } = req.body || {}; + if (!phoneNumber) return res.status(400).json({ message: 'phoneNumber is required' }); + res.json(await requestPairingCode(phoneNumber)); + } catch (e) { + res.status(500).json({ message: e?.response?.data?.message || e.message }); + } +}; + +const logoutHandler = async (req, res) => { + try { + res.json(await logoutSession()); + } catch (e) { + res.status(500).json({ message: e?.response?.data?.message || e.message }); + } +}; + +const startHandler = async (req, res) => { + try { + res.json(await startSession()); + } catch (e) { + res.status(500).json({ message: e?.response?.data?.message || e.message }); + } +}; + +const restartHandler = async (req, res) => { + try { + res.json(await restartSession()); + } catch (e) { + res.status(500).json({ message: e?.response?.data?.message || e.message }); + } +}; + +// ─── Webhook — auto-recovery ────────────────────────────────────────────────── + +const MAX_ATTEMPTS = 3; +const RETRY_DELAYS = [5_000, 15_000, 30_000]; // ms between each attempt +const STATUS_WAIT_MS = 10_000; // wait after restart before checking + +const handleWebhook = async (req, res) => { + // Acknowledge immediately so WAWP doesn't time out + res.status(200).json({ ok: true }); + + try { + const { event, session } = req.body || {}; + if (event !== 'session.status') return; + + const status = session?.status; + // Only auto-recover on FAILED — STOPPED may be intentional + if (status !== 'FAILED') return; + + console.warn('[whatsapp webhook] Session FAILED — starting recovery...'); + + let recovered = false; + + for (let i = 0; i < MAX_ATTEMPTS; i++) { + await new Promise(r => setTimeout(r, RETRY_DELAYS[i])); + try { + await restartSession(); + await new Promise(r => setTimeout(r, STATUS_WAIT_MS)); + const info = await getStatus(); + const s = info?.status; + if (s === 'WORKING' || s === 'SCAN_QR_CODE' || s === 'STARTING') { + recovered = true; + console.info(`[whatsapp webhook] Session recovered on attempt ${i + 1} (status: ${s})`); + break; + } + console.warn(`[whatsapp webhook] Attempt ${i + 1}: status still ${s}`); + } catch (e) { + console.warn(`[whatsapp webhook] Attempt ${i + 1} error:`, e.message); + } + } + + if (!recovered) { + console.error(`[whatsapp webhook] Could not recover after ${MAX_ATTEMPTS} attempts — sending admin alert`); + const { getSettingSync } = require('../utils/settingsCache'); + const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '') + || getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''); + const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/whatsapp`; + const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' }); + await sendMail({ + to: adminEmail, + subject: 'WhatsApp session is down — action required', + text: `The Hope Events WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`, + html: `

The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.

Time: ${when}

Please visit the admin dashboard to re-scan the QR code and reconnect.

`, + }).catch(() => {}); + } + } catch (e) { + console.error('[whatsapp webhook] Unhandled error in recovery handler:', e.message); + } +}; + +module.exports = { + getConfigHandler, + saveConfigHandler, + createInstanceHandler, + deleteInstanceHandler, + getStatusHandler, + getQrHandler, + requestCodeHandler, + logoutHandler, + startHandler, + restartHandler, + handleWebhook, +}; \ No newline at end of file diff --git a/backend/src/controllers/yocoTransactionsController.js b/backend/src/controllers/yocoTransactionsController.js new file mode 100644 index 0000000..d1efcec --- /dev/null +++ b/backend/src/controllers/yocoTransactionsController.js @@ -0,0 +1,272 @@ +const prisma = require('../config/db'); +const { generateTicketsForRegistration } = require('../utils/ticketUtils'); +const { emailTickets } = require('./ticketController'); + +function getYocoModel() { + // Gracefully handle environments where the Prisma client hasn't been regenerated + // and YocoTransaction model is not available yet. + return prisma && prisma.yocoTransaction ? prisma.yocoTransaction : null; +} + +// @desc Get all Yoco transactions (optionally filter by reconciled and ignored) +// @route GET /api/yoco-transactions +// @access Private/Admin or Supervisor +const getAllYocoTransactions = async (req, res) => { + try { + if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) { + return res.status(403).json({ success: false, message: 'Forbidden' }); + } + + const { reconciled, ignored } = req.query; + const where = {}; + if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true'; + if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true'; + + const Yoco = getYocoModel(); + if (!Yoco) { + console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?'); + return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' }); + } + + const items = await Yoco.findMany({ + where, + orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }] + }); + + return res.status(200).json({ success: true, data: items }); + } catch (error) { + console.error('Failed to fetch Yoco transactions:', error); + return res.status(500).json({ success: false, message: 'Server error', details: error.message }); + } +}; + +// @desc Get unreconciled Yoco transactions (not reconciled and not ignored) +// @route GET /api/yoco-transactions/unreconciled +// @access Private/Admin or Supervisor +const getUnreconciledYocoTransactions = async (req, res) => { + try { + // Basic role check if auth middleware sets req.user + if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) { + return res.status(403).json({ success: false, message: 'Forbidden' }); + } + + const Yoco = getYocoModel(); + if (!Yoco) { + console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?'); + return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' }); + } + + const items = await Yoco.findMany({ + where: { reconciled: false, ignored: false }, + orderBy: { createdDate: 'desc' } + }); + + return res.status(200).json({ success: true, data: items }); + } catch (error) { + console.error('Failed to fetch unreconciled Yoco transactions:', error); + return res.status(500).json({ success: false, message: 'Server error', details: error.message }); + } +}; + +// @desc Reconcile a Yoco transaction to a Registration or Donation (creates Payment) +// @route POST /api/yoco-transactions/:id/reconcile +// @access Private/Admin or Supervisor +const reconcileYocoTransaction = async (req, res) => { + try { + if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) { + return res.status(403).json({ success: false, message: 'Forbidden' }); + } + + const Yoco = getYocoModel(); + if (!Yoco) { + return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' }); + } + + const { id } = req.params; + const { registrationId, eventId } = req.body || {}; + + const ytx = await Yoco.findUnique({ where: { id } }); + if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' }); + if (ytx.reconciled && ytx.paymentId) { + return res.status(409).json({ success: false, message: 'Already reconciled', data: ytx }); + } + + // Load registration if provided + let registration = null; + if (registrationId) { + registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + event: true, + user: true, + registrationOptions: { include: { eventOption: true } }, + payments: true + } + }); + if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' }); + } + + // Determine payment fields + const amountFloat = typeof ytx.amount === 'number' ? (ytx.amount / 100) : 0; + if (!amountFloat || amountFloat <= 0) { + return res.status(400).json({ success: false, message: 'Invalid amount on YocoTransaction for reconciliation' }); + } + + const externalId = ytx.externalId || undefined; + + // Resolve a valid userId for the Payment to satisfy FK constraints + let resolvedUserId = null; + if (registration?.userId) { + resolvedUserId = registration.userId; + } else if (ytx?.raw?.payload?.metadata?.userId) { + const metaUserId = String(ytx.raw.payload.metadata.userId); + try { + const exists = await prisma.user.findUnique({ where: { id: metaUserId } }); + if (exists) resolvedUserId = metaUserId; + } catch {} + } + if (!resolvedUserId && req.user?.id) { + // Fallback to the acting supervisor/admin to avoid FK violations + resolvedUserId = req.user.id; + } + if (!resolvedUserId) { + return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' }); + } + + // Build payment data + const paymentData = { + amount: amountFloat, + method: ytx.methodType || 'card', + userId: resolvedUserId, + registrationId: registration?.id || null, + eventId: registration?.eventId || eventId || null, + isDonation: !registration?.id, + externalId: externalId, + status: 'completed' + }; + + // Ensure donation has an eventId + if (!registration && !paymentData.eventId) { + return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' }); + } + + // Create payment + // Preserve original Yoco creation time to correctly evaluate early-bird pricing + const createdAt = ytx.createdDate || ytx.createdAt || new Date(); + const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } }); + + // Optionally update registration status when applicable and generate/email tickets if paid + let generatedTickets = []; + if (registration) { + try { + const updatedReg = await updateRegistrationStatus(registration.id); + if (updatedReg && updatedReg.status === 'paid') { + try { + generatedTickets = await generateTicketsForRegistration(registration.id); + if (Array.isArray(generatedTickets) && generatedTickets.length > 0) { + try { + const mockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } }; + const mockRes = { status: () => mockRes, json: () => {} }; + await emailTickets(mockReq, mockRes); + } catch (emailErr) { + console.error('Error emailing tickets after Yoco reconciliation:', emailErr); + } + } + } catch (genErr) { + console.error('Error generating tickets after Yoco reconciliation:', genErr); + } + } + } catch (e) { + // log but do not fail reconciliation + console.warn('Failed to update registration after reconciliation:', e?.message || e); + } + } + + // Send emails for the reconciled payment + try { + const { sendPaymentEmails } = require('../utils/notifications'); + await sendPaymentEmails(payment.id); + } catch (e) { + console.error('Failed to send payment emails after reconciliation:', e); + } + + // Update yoco transaction as reconciled + const updatedTx = await Yoco.update({ + where: { id: ytx.id }, + data: { reconciled: true, paymentId: payment.id } + }); + + return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined } }); + } catch (error) { + console.error('Failed to reconcile Yoco transaction:', error); + // Handle unique externalId conflicts (if a Payment with same externalId already exists) + if (error?.code === 'P2002') { + try { + const existing = await prisma.payment.findFirst({ where: { externalId: (await getYocoModel()?.findUnique({ where: { id: req.params.id } }))?.externalId } }); + if (existing) { + const updatedTx = await getYocoModel()?.update({ where: { id: req.params.id }, data: { reconciled: true, paymentId: existing.id } }); + if (updatedTx) { + return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment: existing }, message: 'Linked to existing payment' }); + } + } + } catch {} + } + return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) }); + } +}; + +// Minimal registration status update mirroring webhook logic +async function updateRegistrationStatus(registrationId) { + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true + } + }); + if (!registration) return null; + const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date()); + let newStatus = 'pending'; + if (totalPaid >= totalDue) newStatus = 'paid'; + else if (totalPaid > 0) newStatus = 'partial_paid'; + return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } }); +} + +// @desc Ignore a Yoco transaction (mark as ignored and reconciled without creating a payment) +// @route POST /api/yoco-transactions/:id/ignore +// @access Private/Admin or Supervisor +const ignoreYocoTransaction = async (req, res) => { + try { + if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) { + return res.status(403).json({ success: false, message: 'Forbidden' }); + } + const Yoco = getYocoModel(); + if (!Yoco) { + return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' }); + } + const { id } = req.params; + const ytx = await Yoco.findUnique({ where: { id } }); + if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' }); + + if (ytx.reconciled && ytx.ignored) { + return res.status(200).json({ success: true, data: ytx, message: 'Already ignored' }); + } + + const updated = await Yoco.update({ + where: { id }, + data: { ignored: true, reconciled: true } + }); + return res.status(200).json({ success: true, data: updated }); + } catch (error) { + console.error('Failed to ignore Yoco transaction:', error); + return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) }); + } +}; + +module.exports = { + getAllYocoTransactions, + getUnreconciledYocoTransactions, + reconcileYocoTransaction, + ignoreYocoTransaction +}; diff --git a/backend/src/favicon.ico b/backend/src/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/backend/src/favicon.ico differ diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..efe6ae1 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,1253 @@ +const express = require('express'); +const path = require('path'); +const { version: API_VERSION } = require('../package.json'); +const cors = require('cors'); +const rateLimit = require('express-rate-limit'); +const dotenv = require('dotenv'); +const { PrismaClient } = require('@prisma/client'); +const { notFound, errorHandler } = require('./middleware/errorMiddleware'); +const getRawBody = require('raw-body'); + +// Load environment variables +dotenv.config(); + +// Initialize Prisma client +const prisma = new PrismaClient(); + +// Initialize Express app +const app = express(); +const PORT = process.env.PORT || 3000; + +// CORS — allow only the configured frontend origin +const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000') + .split(',') + .map((o) => o.trim()); + +const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; +const isTesting = process.env.NODE_ENV === 'testing'; +const isProd = process.env.NODE_ENV === 'production'; +const modeLabel = isProd ? 'production' : isTesting ? 'testing' : 'development'; +console.log(`Running in ${modeLabel} mode.`); + +// In testing or production, require all critical env vars to be set at startup +if (isTesting || isProd) { + const required = ['DATABASE_URL', 'JWT_SECRET']; + const missing = required.filter(k => !process.env[k]); + if (missing.length > 0) { + console.error(`[startup] Missing required environment variables for ${modeLabel} mode: ${missing.join(', ')}`); + process.exit(1); + } +} + +app.use(cors({ + origin: (origin, callback) => { + // Allow requests with no origin (e.g. mobile apps, curl, Postman) + if (!origin) return callback(null, true); + // Always allow configured origins + if (allowedOrigins.includes(origin)) return callback(null, true); + // In dev or testing, also allow any LAN origin on the same port (e.g. phone on 192.168.x.x:3000) + if (isDev || isTesting) { + try { + const u = new URL(origin); + const isLan = /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(u.hostname); + if (isLan) return callback(null, true); + } catch {} + } + callback(new Error(`CORS: origin '${origin}' not allowed`)); + }, + credentials: true, +})); + +// Global rate limiter — 5000 requests per 5 minutes per IP +// Webhooks are excluded because they come from Yoco's servers and are already HMAC-verified +const globalLimiter = rateLimit({ + windowMs: 5 * 60 * 1000, + max: 5000, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many requests, please try again later.' }, + skip: (req) => req.path.startsWith('/api/webhooks'), +}); +app.use(globalLimiter); + +// Raw body parsing middleware for webhooks +app.use((req, res, next) => { + if (req.path.startsWith('/api/webhooks')) { + getRawBody(req, { + length: req.headers['content-length'], + encoding: 'utf-8' + }, (err, rawBody) => { + if (err) return next(err); + req.rawBody = rawBody; + next(); + }); + } else { + next(); + } +}); + + +// Import routes +const userRoutes = require('./routes/userRoutes'); +const eventRoutes = require('./routes/eventRoutes'); +const registrationRoutes = require('./routes/registrationRoutes'); +const paymentRoutes = require('./routes/paymentRoutes'); +const ticketRoutes = require('./routes/ticketRoutes'); +const webhookRoutes = require('./routes/webhookRoutes'); +const uploadRoutes = require('./routes/uploadRoutes'); +const reportRoutes = require('./routes/reportRoutes'); +const formRoutes = require('./routes/formRoutes'); +const yocoTransactionRoutes = require('./routes/yocoTransactionRoutes'); +const automationRoutes = require('./routes/automationRoutes'); +const broadcastRoutes = require('./routes/broadcastRoutes'); +const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes'); +const scheduledEmailRoutes = require('./routes/scheduledEmailRoutes'); +const sectionRoutes = require('./routes/sectionRoutes'); +const bannerRoutes = require('./routes/bannerRoutes'); +const whatsappRoutes = require('./routes/whatsappRoutes'); +const settingsRoutes = require('./routes/settingsRoutes'); +const setupRoutes = require('./routes/setupRoutes'); +const costRoutes = require('./routes/costRoutes'); +const cashupRoutes = require('./routes/cashupRoutes'); +const statsRoutes = require('./routes/statsRoutes'); + +// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream +app.use('/api/webhooks', webhookRoutes); + +// Apply JSON body parser for all non-webhook routes +app.use(express.json()); + +// Use routes +app.use('/api/users', userRoutes); +app.use('/api/events', eventRoutes); +app.use('/api/registrations', registrationRoutes); +app.use('/api/payments', paymentRoutes); +app.use('/api/tickets', ticketRoutes); +app.use('/api/uploads', uploadRoutes); +app.use('/api/reports', reportRoutes); +app.use('/api/forms', formRoutes); +app.use('/api/yoco-transactions', yocoTransactionRoutes); +app.use('/api/automations', automationRoutes); +app.use('/api/broadcasts', broadcastRoutes); +app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes); +app.use('/api/scheduled-emails', scheduledEmailRoutes); +app.use('/api/sections', sectionRoutes); +app.use('/api/banner', bannerRoutes); +app.use('/api/whatsapp', whatsappRoutes); +app.use('/api/settings', settingsRoutes); +app.use('/api/setup', setupRoutes); +app.use('/api/stats', statsRoutes); +app.use('/api', costRoutes); +app.use('/api/cashups', cashupRoutes); + +// Pre-warm the settings cache so synchronous helpers have DB values from startup +require('./utils/settingsCache').warmCache().catch(() => {}); +app.use('/uploads', express.static('public/uploads')); + +// ── Shared page helpers ──────────────────────────────────────────────────────── +const jwt = require('jsonwebtoken'); + +function pageShell(title, accentColor, bodyHtml) { + return ` + + + + + ${title} + + + +
+${bodyHtml} +
+ +`; +} + +function formatUptime(seconds) { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1048576).toFixed(1)} MB`; +} + +// ── Favicon ────────────────────────────────────────────────────────────────── +app.get('/favicon.ico', (req, res) => { + res.sendFile(path.join(__dirname, 'favicon.ico')); +}); + +// ── Root route — status page ──────────────────────────────────────────────── +app.get('/', async (req, res) => { + const mem = process.memoryUsage(); + const uptimeSec = process.uptime(); + const now = new Date().toISOString(); + + // Database health check + let dbStatus = 'ok'; + let dbMsg = 'Connected'; + try { + await prisma.$queryRaw`SELECT 1`; + } catch (e) { + dbStatus = 'error'; + dbMsg = 'Unreachable'; + } + + const dbBadge = dbStatus === 'ok' + ? `✓ ${dbMsg}` + : `✗ ${dbMsg}`; + + const envBadge = isProd + ? `production` + : isTesting + ? `testing` + : `development`; + + const html = pageShell('Hope Events API — Status', '#2563eb', ` +

Hope Events API

+

v${API_VERSION} — ${now}

+ +
+
+
Status
+
✓ Online
+
+
+
Environment
+
${envBadge}
+
+
+
Database
+
${dbBadge}
+
+
+
Uptime
+
${formatUptime(uptimeSec)}
+
+
+
Heap Used
+
${formatBytes(mem.heapUsed)}
+
+
+
Heap Total
+
${formatBytes(mem.heapTotal)}
+
+
+
RSS
+
${formatBytes(mem.rss)}
+
+
+
Node
+
${process.version}
+
+
+ +
+ API documentation is available at /docs — requires an admin account token. +
+ `); + + res.send(html); +}); + +// ── /docs route — interactive API reference, admin only ───────────────────── +app.get('/docs', async (req, res) => { + const tokenFromQuery = req.query.token; + const tokenFromHeader = req.headers.authorization?.startsWith('Bearer ') + ? req.headers.authorization.split(' ')[1] : null; + const token = tokenFromQuery || tokenFromHeader; + + if (!token) { + return res.status(401).send(pageShell('API Docs — Login Required', '#2563eb', ` +

API Documentation

+

Admin access required

+
+

Pass your admin JWT to view the docs:

+ /docs?token=<your-admin-jwt> +

Copy your token from the browser's localStorage key token after logging in as admin.

+
`)); + } + + let user; + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + user = await prisma.user.findUnique({ + where: { id: decoded.id }, + select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true }, + }); + if (!user || !user.isActive) throw new Error('User not found or inactive'); + if ((decoded.tokenVersion ?? 0) !== user.tokenVersion) throw new Error('Token revoked'); + if (user.role !== 'admin') throw new Error('Admin role required'); + } catch (e) { + return res.status(403).send(pageShell('API Docs — Access Denied', '#dc2626', ` +

Access Denied

+

Admin role required

+
+

${e.message}

+

Make sure you are logged in as an admin and using a valid, non-expired token.

+
`)); + } + + // ── Endpoint data ──────────────────────────────────────────────────────── + const GROUPS = [ + { title: 'Users', base: '/api/users', endpoints: [ + { method:'POST', path:'/api/users', auth:'public', desc:'Register a new account', + request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }}, + responses:[ + { status:201, desc:'Created', body:{ id:'a1b2c3d4-0000-0000-0000-000000000001', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }}, + { status:400, desc:'Validation error', body:{ message:'Email already in use' }}, + ]}, + { method:'POST', path:'/api/users/login', auth:'public', desc:'Login — returns a signed JWT', + notes:'Rate-limited to 15 requests/min per IP.', + request:{ body:{ email:'jane@example.com', password:'secret123' }}, + responses:[ + { status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }}, + { status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }}, + ]}, + { method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email', + request:{ body:{ email:'jane@example.com' }}, + responses:[ + { status:200, desc:'Always succeeds (no user enumeration)', body:{ message:'If that email exists, a reset link has been sent.' }}, + ]}, + { method:'POST', path:'/api/users/reset', auth:'public', desc:'Reset password using token from email', + request:{ body:{ token:'reset-token-from-email', password:'newPassword123' }}, + responses:[ + { status:200, desc:'Success', body:{ message:'Password reset successful.' }}, + { status:400, desc:'Invalid or expired token', body:{ message:'Reset token is invalid or has expired.' }}, + ]}, + { method:'POST', path:'/api/users/activate', auth:'public', desc:'Activate a new account via email link', + request:{ body:{ token:'activation-token-from-email' }}, + responses:[ + { status:200, desc:'Success', body:{ message:'Account activated.' }}, + { status:400, desc:'Invalid token', body:{ message:'Invalid or expired activation token.' }}, + ]}, + { method:'GET', path:'/api/users/profile', auth:'user+', desc:'Get own profile', + responses:[ + { status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', phoneNumber:'+27821234567', notificationPreference:'email', createdAt:'2024-01-15T08:00:00.000Z' }}, + { status:401, desc:'Unauthorized', body:{ message:'Not authorized, no token' }}, + ]}, + { method:'PUT', path:'/api/users/profile', auth:'user+', desc:'Update own profile', + request:{ body:{ name:'Jane Smith', phoneNumber:'+27821234567', notificationPreference:'both' }}, + responses:[ + { status:200, desc:'Updated', body:{ id:'a1b2c3d4-...', name:'Jane Smith', email:'jane@example.com', notificationPreference:'both' }}, + ]}, + { method:'POST', path:'/api/users/revoke-sessions', auth:'user+', desc:'Invalidate all own sessions by incrementing tokenVersion', + responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]}, + { method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account', + responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]}, + { method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.', + queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' }, + responses:[ + { status:200, desc:'Success', body:{ data:[{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, createdAt:'2024-01-15T08:00:00.000Z' }], total:1, page:1, limit:20 }}, + ]}, + { method:'GET', path:'/api/users/check-exists', auth:'supervisor+', desc:'Check whether an account already exists for a given email and/or phone number (used by the self-service kiosk and manual registration screens to avoid duplicate accounts)', + queryParams:{ email:'Email to look up (optional)', phone:'Phone number to look up (optional; SA formats normalized)' }, + responses:[ + { status:200, desc:'Success', body:{ exists:true, hasEmail:true, hasPhone:false }}, + ]}, + { method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID', + pathParams:{ ':id':'User UUID' }, + responses:[ + { status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, phoneNumber:'+27821234567', notificationPreference:'email' }}, + { status:404, desc:'Not found', body:{ message:'User not found' }}, + ]}, + { method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role)', + pathParams:{ ':id':'User UUID' }, + request:{ body:{ role:'supervisor', isActive:true, name:'Jane Doe' }}, + responses:[ + { status:200, desc:'Updated', body:{ id:'a1b2c3d4-...', name:'Jane Doe', role:'supervisor' }}, + ]}, + { method:'DELETE', path:'/api/users/:id', auth:'admin', desc:'Deactivate a user account (sets isActive: false; does not erase data)', + pathParams:{ ':id':'User UUID' }, + responses:[ + { status:200, desc:'Deactivated', body:{ message:'User deactivated' }}, + { status:404, desc:'Not found', body:{ message:'User not found' }}, + ]}, + { method:'POST', path:'/api/users/:id/revoke-sessions', auth:'admin', desc:'Revoke all sessions for another user', + pathParams:{ ':id':'User UUID' }, + responses:[{ status:200, desc:'Success', body:{ message:'Sessions revoked for user.' }}]}, + { method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user — sets name to "Deleted User", email to deleted-{id}@deleted.local, clears phone number, deactivates account, revokes all sessions. Irreversible.', + pathParams:{ ':id':'User UUID' }, + responses:[ + { status:200, desc:'Anonymised', body:{ message:'User data deleted' }}, + { status:404, desc:'Not found', body:{ message:'User not found' }}, + ]}, + ]}, + + { title: 'Events', base: '/api/events', endpoints: [ + { method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.', + queryParams:{ limit:'Max results (default 20)' }, + responses:[ + { status:200, desc:'Success', body:[{ id:'ev-uuid-...', title:'Camp 2025', startDate:'2025-07-10T08:00:00.000Z', endDate:'2025-07-14T17:00:00.000Z', price:450, picture:'/uploads/camp.jpg', isActive:true, isSoldOut:false }]}, + ]}, + { method:'GET', path:'/api/events/all', auth:'staff+', desc:'All events including hidden and past', + queryParams:{ includePast:'Include past events (true|false, default false)' }, + responses:[{ status:200, desc:'Success', body:[{ id:'ev-uuid-...', title:'Camp 2025', isHidden:false, isActive:true }]}]}, + { method:'GET', path:'/api/events/:id', auth:'public', desc:'Get full event detail by ID. When called by staff/supervisor/admin, the response also includes createdBy and notifyRecipients (who the event\'s notifications go to).', + pathParams:{ ':id':'Event UUID' }, + responses:[ + { status:200, desc:'Success', body:{ id:'ev-uuid-...', title:'Camp 2025', description:'Annual family camp.', startDate:'2025-07-10T08:00:00.000Z', endDate:'2025-07-14T17:00:00.000Z', price:450, requiresAuth:true, eventOptions:[{ id:'opt-uuid-...', name:'Adult', price:450, isMainTicket:true }], createdBy:{ id:'user-uuid-...', name:'Jane Supervisor', email:'jane@example.com' }, notifyRecipients:[]}}, + { status:404, desc:'Not found', body:{ message:'Event not found' }}, + ]}, + { method:'GET', path:'/api/events/by-alias/:redirectUrl', auth:'public', desc:'Get event by its URL alias', + pathParams:{ ':redirectUrl':'URL alias string (e.g. camp-2025)' }, + responses:[{ status:200, desc:'Success', body:{ id:'ev-uuid-...', title:'Camp 2025', redirectUrl:'camp-2025' }}]}, + { method:'POST', path:'/api/events', auth:'supervisor+', desc:'Create a new event', + request:{ body:{ title:'Camp 2025', description:'Annual family camp', startDate:'2025-07-10T08:00:00.000Z', endDate:'2025-07-14T17:00:00.000Z', registrationDeadline:'2025-07-01T00:00:00.000Z', price:450, isActive:true, isHidden:false, requiresAuth:true }}, + responses:[ + { status:201, desc:'Created', body:{ id:'ev-uuid-new', title:'Camp 2025', createdAt:'2024-11-01T09:00:00.000Z' }}, + ]}, + { method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event', + pathParams:{ ':id':'Event UUID' }, + request:{ body:{ title:'Camp 2025 Updated', price:500 }}, + responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]}, + { method:'GET', path:'/api/events/:id/notify-recipients', auth:'supervisor+', desc:'Get which users receive registration/payment/daily-summary notifications for this event. Lightweight — skips the option/stock computation that GET /api/events/:id does.', + pathParams:{ ':id':'Event UUID' }, + responses:[{ status:200, desc:'Success', body:[{ id:'user-uuid-1', name:'Jane Supervisor', email:'jane@example.com', role:'supervisor' }]}]}, + { method:'PUT', path:'/api/events/:id/notify-recipients', auth:'supervisor+', desc:'Set which users receive registration/payment/daily-summary notifications for this event. Pass an empty array to fall back to the event creator.', + pathParams:{ ':id':'Event UUID' }, + request:{ body:{ userIds:['user-uuid-1','user-uuid-2'] }}, + responses:[ + { status:200, desc:'Saved', body:[{ id:'user-uuid-1', name:'Jane Supervisor', email:'jane@example.com', role:'supervisor' },{ id:'user-uuid-2', name:'John Admin', email:'john@example.com', role:'admin' }]}, + { status:400, desc:'Bad input', body:{ message:'userIds must be an array' }}, + ]}, + { method:'DELETE', path:'/api/events/:id', auth:'admin', desc:'Permanently delete an event', + pathParams:{ ':id':'Event UUID' }, + responses:[{ status:200, desc:'Deleted', body:{ message:'Event deleted.' }}]}, + { method:'POST', path:'/api/events/:id/email-attendees', auth:'supervisor+', desc:'Send a bulk email to event attendees. Use dryRun:true to preview recipients without sending.', + pathParams:{ ':id':'Event UUID' }, + request:{ body:{ subject:'Payment reminder: Camp 2025', text:'Hi {{name}}, you have an outstanding balance of {{balance}}.', filter:{ status:'unpaid', attendeeIds:['user-uuid-1','user-uuid-2'] }, dryRun:false, template:'custom' }}, + responses:[ + { status:200, desc:'Sent', body:{ matched:15, sent:14, failed:1 }}, + { status:200, desc:'Dry run', body:{ matched:15, dryRun:true, recipients:[{ email:'jane@example.com', name:'Jane Doe' }]}}, + ]}, + { method:'POST', path:'/api/events/:id/email-attendees/schedule', auth:'supervisor+', desc:'Schedule a bulk email to run at a future time', + pathParams:{ ':id':'Event UUID' }, + request:{ body:{ subject:'Reminder: Camp 2025', text:'Hi {{name}}, see you soon!', scheduledAt:'2025-07-03T09:00:00.000Z', filter:{ status:'paid' }}}, + responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', scheduledAt:'2025-07-03T09:00:00.000Z', status:'queued' }}}]}, + { method:'POST', path:'/api/events/:id/whatsapp-attendees', auth:'supervisor+', desc:'Send a bulk WhatsApp message to attendees with a phone number. Supports dryRun.', + pathParams:{ ':id':'Event UUID' }, + request:{ body:{ message:'Hi {{name}}, camp starts in 3 days! 🏕️', filter:{ status:'paid' }, dryRun:false }}, + responses:[ + { status:200, desc:'Sent', body:{ matched:12, sent:11 }}, + ]}, + { method:'POST', path:'/api/events/:id/whatsapp-attendees/schedule', auth:'supervisor+', desc:'Schedule a bulk WhatsApp message', + pathParams:{ ':id':'Event UUID' }, + request:{ body:{ message:'Hi {{name}}, final reminder for camp tomorrow! 🎉', scheduledAt:'2025-07-09T08:00:00.000Z', filter:{ status:'paid' }}}, + responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', status:'queued' }}}]}, + { method:'POST', path:'/api/events/:id/options', auth:'supervisor+', desc:'Add a ticket option to an event', + pathParams:{ ':id':'Event UUID' }, + request:{ body:{ name:'Child (under 12)', price:250, isMainTicket:false }}, + responses:[{ status:201, desc:'Created', body:{ id:'opt-uuid-new', name:'Child (under 12)', price:250, isMainTicket:false }}]}, + { method:'PUT', path:'/api/events/options/:id', auth:'supervisor+', desc:'Update a ticket option', + pathParams:{ ':id':'EventOption UUID' }, + request:{ body:{ price:300 }}, + responses:[{ status:200, desc:'Updated', body:{ id:'opt-uuid-...', price:300 }}]}, + { method:'DELETE', path:'/api/events/options/:id', auth:'admin', desc:'Delete a ticket option (fails if registrations exist)', + pathParams:{ ':id':'EventOption UUID' }, + responses:[ + { status:200, desc:'Deleted', body:{ message:'Option deleted.' }}, + { status:400, desc:'In use', body:{ message:'Cannot delete option with existing registrations.' }}, + ]}, + ]}, + + { title: 'Registrations', base: '/api/registrations', endpoints: [ + { method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.', + request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }}, + responses:[ + { status:201, desc:'Created', body:{ id:'reg-uuid-...', eventId:'ev-uuid-...', status:'pending', checkoutId:null, createdAt:'2025-06-01T10:00:00.000Z' }}, + { status:400, desc:'Deadline passed', body:{ message:'Registration deadline has passed.' }}, + ]}, + { method:'GET', path:'/api/registrations/myregistrations', auth:'user+', desc:'Get all registrations for the authenticated user', + responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', event:{ id:'ev-uuid-...', title:'Camp 2025' }, status:'paid', createdAt:'2025-06-01T10:00:00.000Z' }]}]}, + { method:'GET', path:'/api/registrations/:id', auth:'optional', desc:'Get registration detail by ID', + pathParams:{ ':id':'Registration UUID' }, + responses:[{ status:200, desc:'Success', body:{ id:'reg-uuid-...', status:'paid', event:{ title:'Camp 2025' }, registrationOptions:[{ eventOption:{ name:'Adult' }, quantity:1 }], payments:[{ amount:450, method:'card', status:'succeeded' }] }}]}, + { method:'PUT', path:'/api/registrations/:id', auth:'staff+', desc:'Update registration status. If set to "paid", generates tickets and emails them fire-and-forget. If downgraded from "paid", deletes unused tickets (blocks if any ticket has been scanned).', + pathParams:{ ':id':'Registration UUID' }, + request:{ body:{ status:'paid' }}, + responses:[{ status:200, desc:'Updated', body:{ id:'reg-uuid-...', status:'paid' }}]}, + { method:'DELETE', path:'/api/registrations/:id', auth:'user+', desc:'Cancel registration. Users: only allowed when no positive payments exist (returns 403 otherwise). Admins: always allowed.', + pathParams:{ ':id':'Registration UUID' }, + responses:[ + { status:200, desc:'Cancelled', body:{ message:'Registration cancelled', registration:{ id:'reg-uuid-...', status:'cancelled' }}}, + { status:400, desc:'Already cancelled', body:{ message:'Registration is already cancelled' }}, + { status:403, desc:'Payments exist — non-admin cannot cancel', body:{ message:'This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.' }}, + { status:403, desc:'Not the owner (and not admin)', body:{ message:'Not authorized to cancel this registration' }}, + ]}, + { method:'GET', path:'/api/registrations', auth:'staff+', desc:'List all registrations across all events', + queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', status:'Filter by status', search:'Search by name/email' }, + responses:[{ status:200, desc:'Success', body:{ data:[{ id:'reg-uuid-...', user:{ name:'Jane Doe' }, event:{ title:'Camp 2025' }, status:'paid' }], total:1, page:1 }}]}, + { method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event', + pathParams:{ ':eventId':'Event UUID' }, + responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]}, + { method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.', + request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }}, + responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]}, + { method:'POST', path:'/api/registrations/:id/forms/responses', auth:'optional', desc:'Submit form responses for a registration', + pathParams:{ ':id':'Registration UUID' }, + request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }}, + responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]}, + ]}, + + { title: 'Payments', base: '/api/payments', endpoints: [ + { method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.', + request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.hopehenley.co.za/payment/success', cancelUrl:'https://events.hopehenley.co.za/payment/cancel', failureUrl:'https://events.hopehenley.co.za/payment/failure' }}, + responses:[ + { status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }}, + { status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }}, + ]}, + { method:'POST', path:'/api/payments', auth:'supervisor+', desc:'Record a manual payment (cash, EFT, etc.)', + request:{ body:{ registrationId:'reg-uuid-...', amount:450, method:'cash', userId:'user-uuid-...' }}, + responses:[ + { status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }}, + ]}, + { method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history', + responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }]}]}, + { method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments', + queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' }, + responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]}, + { method:'GET', path:'/api/payments/event/:eventId', auth:'staff+', desc:'All payments for an event', + pathParams:{ ':eventId':'Event UUID' }, + responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, user:{ name:'Jane Doe' }, method:'card' }]}]}, + { method:'POST', path:'/api/payments/refund', auth:'supervisor+', desc:'Create a refund payment (negative amount)', + request:{ body:{ originalPaymentId:'pay-uuid-...', amount:450, reason:'Cancelled registration' }}, + responses:[{ status:201, desc:'Refund recorded', body:{ id:'pay-uuid-refund', amount:-450, method:'refund' }}]}, + { method:'PUT', path:'/api/payments/assign-donation', auth:'supervisor+', desc:'Link an unassigned donation payment to a specific registration', + request:{ body:{ paymentId:'pay-uuid-...', registrationId:'reg-uuid-...' }}, + responses:[{ status:200, desc:'Assigned', body:{ id:'pay-uuid-...', registrationId:'reg-uuid-...', isDonation:false }}]}, + { method:'GET', path:'/api/payments/admin/stats', auth:'admin', desc:'Aggregated payment statistics', + responses:[{ status:200, desc:'Success', body:{ totalRevenue:98500, totalPayments:215, byMethod:{ card:180, cash:30, eft:5 }, byEvent:[{ eventId:'ev-uuid-...', title:'Camp 2025', total:45000 }] }}]}, + ]}, + + { title: 'Tickets', base: '/api/tickets', endpoints: [ + { method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets', + responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]}, + { method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)', + request:{ body:{ registrationId:'reg-uuid-...' }}, + responses:[{ status:201, desc:'Generated', body:{ created:2, skipped:0, tickets:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123' }] }}]}, + { method:'POST', path:'/api/tickets/scan/:qrCode', auth:'staff+', desc:'Scan and redeem a ticket', + pathParams:{ ':qrCode':'QR code string from ticket' }, + request:{ body:{ quantity:1 }}, + responses:[ + { status:200, desc:'Scanned', body:{ ticket:{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', isUsed:true }, event:{ title:'Camp 2025' }, user:{ name:'Jane Doe' }, quantityRedeemed:1, remainingUses:0 }}, + { status:400, desc:'Already used', body:{ message:'Ticket has already been fully redeemed.' }}, + { status:404, desc:'Not found', body:{ message:'Ticket not found.' }}, + ]}, + { method:'GET', path:'/api/tickets/scan-preview/:qrCode', auth:'staff+', desc:'Preview a ticket scan without marking it used', + pathParams:{ ':qrCode':'QR code string' }, + responses:[{ status:200, desc:'Preview', body:{ ticket:{ qrCode:'TKT-ABC123', quantity:2, isUsed:false }, event:{ title:'Camp 2025' }, user:{ name:'Jane Doe', phoneNumber:'+27821234567' }, totalRedeemed:0 }}]}, + { method:'POST', path:'/api/tickets/email', auth:'user+', desc:'Email own tickets as PDF attachments', + responses:[{ status:200, desc:'Sent', body:{ message:'Tickets emailed.', count:2 }}]}, + { method:'POST', path:'/api/tickets/send-to', auth:'staff+', desc:'Send tickets to a specific user by email or WhatsApp', + request:{ body:{ userId:'user-uuid-...', eventId:'ev-uuid-...', channel:'email' }}, + responses:[{ status:200, desc:'Sent', body:{ sent:2 }}]}, + { method:'GET', path:'/api/tickets/event/:eventId', auth:'staff+', desc:'All tickets for an event', + pathParams:{ ':eventId':'Event UUID' }, + responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', user:{ name:'Jane Doe' }, isUsed:false, quantity:1 }]}]}, + { method:'GET', path:'/api/tickets/scans/recent', auth:'staff+', desc:'Last 50 scan events across all events', + responses:[{ status:200, desc:'Success', body:[{ ticketId:'tkt-uuid-...', scannedAt:'2025-07-10T09:05:00.000Z', quantityRedeemed:1, ticket:{ user:{ name:'Jane Doe' }, event:{ title:'Camp 2025' }}, scannedBy:{ name:'Staff Member' }}]}]}, + { method:'GET', path:'/api/tickets/scans/stats', auth:'staff+', desc:'Scan statistics grouped by event', + responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]}, + ]}, + + { title: 'Broadcasts (Email)', base: '/api/broadcasts', endpoints: [ + { method:'POST', path:'/api/broadcasts/preview', auth:'supervisor+', desc:'Dry-run a broadcast — returns resolved recipient list without sending', + request:{ body:{ userIds:['user-uuid-1','user-uuid-2'], emails:'extra@example.com\nJohn ', eventId:'ev-uuid-...' }}, + responses:[{ status:200, desc:'Preview', body:{ matched:3, recipients:[{ email:'jane@example.com', name:'Jane Doe' },{ email:'extra@example.com', name:null }] }}]}, + { method:'POST', path:'/api/broadcasts/send', auth:'supervisor+', desc:'Send an email broadcast to users and/or ad-hoc addresses', + request:{ body:{ subject:'Important update', text:'Hi {{name}}, here is a message for you.', userIds:['user-uuid-1'], emails:'extra@example.com', eventId:'ev-uuid-...' }}, + responses:[{ status:200, desc:'Sent', body:{ matched:2, sent:2 }}]}, + { method:'POST', path:'/api/broadcasts/schedule', auth:'supervisor+', desc:'Schedule an email broadcast for a future time', + request:{ body:{ subject:'Save the date!', text:'Hi {{name}}, mark your calendar for {{event.title}}.', userIds:['user-uuid-1'], scheduledAt:'2025-06-01T09:00:00.000Z', eventId:'ev-uuid-...' }}, + responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', status:'queued', scheduledAt:'2025-06-01T09:00:00.000Z' }}}]}, + ]}, + + { title: 'Broadcasts (WhatsApp)', base: '/api/whatsapp-broadcasts', endpoints: [ + { method:'POST', path:'/api/whatsapp-broadcasts/preview', auth:'supervisor+', desc:'Dry-run — resolve phone list without sending', + request:{ body:{ userIds:['user-uuid-1'], phones:'0821234567\nJane <0721234567>', eventId:'ev-uuid-...' }}, + responses:[{ status:200, desc:'Preview', body:{ matched:2, recipients:[{ phone:'+27821234567', name:'Jane Doe' }] }}]}, + { method:'POST', path:'/api/whatsapp-broadcasts/send', auth:'supervisor+', desc:'Send a WhatsApp broadcast', + request:{ body:{ message:'Hi {{name}}, please check your tickets for {{event.title}}.', userIds:['user-uuid-1'], phones:'0821234567', eventId:'ev-uuid-...' }}, + responses:[{ status:200, desc:'Sent', body:{ matched:2, sent:2 }}]}, + { method:'POST', path:'/api/whatsapp-broadcasts/schedule', auth:'supervisor+', desc:'Schedule a WhatsApp broadcast', + request:{ body:{ message:'Hi {{name}}! {{event.title}} is tomorrow. See you there 🙌', userIds:['user-uuid-1'], scheduledAt:'2025-07-09T08:00:00.000Z' }}, + responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', status:'queued' }}}]}, + ]}, + + { title: 'Scheduled Messages', base: '/api/scheduled-emails', endpoints: [ + { method:'GET', path:'/api/scheduled-emails', auth:'supervisor+', desc:'List all scheduled jobs — email and WhatsApp. Jobs sent more than 7 days ago are hidden.', + responses:[{ status:200, desc:'Success', body:{ jobs:[{ id:'job-uuid-...', channel:'email', broadcast:false, eventId:'ev-uuid-...', status:'queued', scheduledAt:'2025-07-03T09:00:00.000Z', attempts:0, payload:{ subject:'Camp reminder' }}]}}]}, + { method:'PATCH', path:'/api/scheduled-emails/:id', auth:'supervisor+', desc:'Edit a queued job — reschedule or update its message content', + pathParams:{ ':id':'Scheduled job UUID' }, + request:{ body:{ scheduledAt:'2025-07-04T09:00:00.000Z', subject:'Updated subject', text:'Hi {{name}}, updated message.' }}, + responses:[ + { status:200, desc:'Updated', body:{ id:'job-uuid-...', scheduledAt:'2025-07-04T09:00:00.000Z', status:'queued' }}, + { status:400, desc:'Already sent', body:{ message:'Cannot edit a job that has already been sent.' }}, + ]}, + { method:'DELETE', path:'/api/scheduled-emails/:id', auth:'supervisor+', desc:'Cancel and remove a queued job', + pathParams:{ ':id':'Scheduled job UUID' }, + responses:[{ status:200, desc:'Deleted', body:{ message:'Scheduled job removed.' }}]}, + ]}, + + { title: 'Automations', base: '/api/automations', endpoints: [ + { method:'POST', path:'/api/automations/schedule', auth:'supervisor+', desc:'Schedule multiple lifecycle email automations for an event in one call (pre-event, final reminder, thank-you, promo).', + request:{ body:{ eventId:'ev-uuid-...', jobs:[ + { subject:'1 Week to Go: Camp 2025!', text:'Hi {{name}}, camp is one week away!\n\nDetails: {{event.link}}', scheduledAt:'2025-07-03T09:00:00.000Z' }, + { subject:"We'll See You Tomorrow!", text:'Hi {{name}}, final reminder for {{event.title}}. Please have your QR code ready.', scheduledAt:'2025-07-09T09:00:00.000Z' }, + ]}}, + responses:[{ status:201, desc:'Scheduled', body:{ message:'2 automation(s) scheduled.', jobs:[{ id:'job-uuid-1', status:'queued' },{ id:'job-uuid-2', status:'queued' }] }}]}, + ]}, + + { title: 'Reports', base: '/api/reports', endpoints: [ + { method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download', + responses:[{ status:200, desc:'PDF file (application/pdf)', body:'' }]}, + { method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user', + responses:[{ status:200, desc:'Sent', body:{ message:'Report emailed.' }}]}, + ]}, + + { title: 'Costs', base: '/api/events/:eventId/costs', endpoints: [ + { method:'GET', path:'/api/events/:eventId/costs', auth:'supervisor+', desc:'List costs (once-off or per-item) for an event', + pathParams:{ ':eventId':'Event UUID' }, + responses:[{ status:200, desc:'Success', body:[{ id:'cost-uuid-...', label:'Venue hire', costType:'once_off', amount:2500 },{ id:'cost-uuid-2', label:'Catering', costType:'per_item', amount:75, eventOptionId:'opt-uuid-...' }]}]}, + { method:'POST', path:'/api/events/:eventId/costs', auth:'admin', desc:'Create a cost for an event. eventOptionId is required when costType is per_item. paidFromMethod (cash/card/eft/other) optionally tags which float it was paid out of. Rejected once the event is closed.', + pathParams:{ ':eventId':'Event UUID' }, + request:{ body:{ label:'Catering', costType:'per_item', amount:75, eventOptionId:'opt-uuid-...', paidFromMethod:'cash' }}, + responses:[{ status:201, desc:'Created', body:{ id:'cost-uuid-new', label:'Catering', costType:'per_item', amount:75, paidFromMethod:'cash' }}]}, + { method:'PUT', path:'/api/costs/:id', auth:'admin', desc:'Update a cost. Rejected once the event is closed.', + pathParams:{ ':id':'EventCost UUID' }, + request:{ body:{ amount:80 }}, + responses:[{ status:200, desc:'Updated', body:{ id:'cost-uuid-...', amount:80 }}]}, + { method:'DELETE', path:'/api/costs/:id', auth:'admin', desc:'Delete a cost. Rejected once the event is closed.', + pathParams:{ ':id':'EventCost UUID' }, + responses:[{ status:200, desc:'Deleted', body:{ message:'Cost deleted' }}]}, + ]}, + + { title: 'Cashups', base: '/api/cashups', endpoints: [ + { method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history', + pathParams:{ ':eventId':'Event UUID' }, + responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]}, + { method:'PUT', path:'/api/cashups/event/:eventId/draft', auth:'admin', desc:'Save in-progress reconciliation entries (actual counted amounts, and cash denomination counts, per method) without closing the event', + pathParams:{ ':eventId':'Event UUID' }, + request:{ body:{ lines:[{ method:'cash', denominations:[{ value:100, count:5 },{ value:50, count:2 }] },{ method:'card', actualAmount:4500 }] }}, + responses:[{ status:200, desc:'Saved', body:{ message:'Draft saved' }}]}, + { method:'POST', path:'/api/cashups/event/:eventId/close', auth:'admin', desc:'Close the event. Pass "lines" for a full per-method cashup (variances computed against expected cash, i.e. income minus any method-tagged costs) — for the cash line, pass "denominations" and the actual amount is computed from the note/coin counts server-side. Omit "lines" for a quick close that accepts system numbers as-is. Either way, unallocated donations are counted as profit and the event is fully locked (no new registrations, payments, refunds, checkouts, or donations) until an admin reopens it. Creates a permanent EventCashup audit row.', + pathParams:{ ':eventId':'Event UUID' }, + request:{ body:{ lines:[{ method:'cash', denominations:[{ value:100, count:5 },{ value:50, count:2 }], notes:'R20 short' }], notes:'Closed after evening service' }}, + responses:[{ status:201, desc:'Closed', body:{ id:'cashup-uuid-...', action:'closed', unallocatedDonationsTotal:150, totalCosts:900, totalExpectedRevenue:6300, totalActualRevenue:6280 }}]}, + { method:'POST', path:'/api/cashups/event/:eventId/reopen', auth:'admin', desc:'Reopen a closed event, unlocking registrations/payments/donations again. Admin-only. Creates a permanent "reopened" EventCashup audit row.', + pathParams:{ ':eventId':'Event UUID' }, + request:{ body:{ notes:'Reopening to correct a miscounted cash drawer' }}, + responses:[{ status:201, desc:'Reopened', body:{ id:'cashup-uuid-...', action:'reopened' }}]}, + { method:'GET', path:'/api/cashups/audit', auth:'supervisor+', desc:'Flat audit log of every close/quick-close/reopen across events, optionally filtered by eventId and/or from/to dates', + request:{ query:{ eventId:'ev-uuid-... (optional)', from:'2026-01-01 (optional)', to:'2026-12-31 (optional)' }}, + responses:[{ status:200, desc:'Success', body:[{ id:'cashup-uuid-...', action:'closed', event:{ id:'ev-uuid-...', title:'Camp 2025' }, performedBy:{ name:'Jane Supervisor' }, createdAt:'2026-07-20T18:00:00.000Z' }]}]}, + ]}, + + { title: 'Uploads', base: '/api/uploads', endpoints: [ + { method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.', + notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.', + responses:[ + { status:200, desc:'Uploaded', body:{ url:'/uploads/event-images/camp-2025-abc123.jpg' }}, + { status:400, desc:'No file', body:{ message:'No image file provided.' }}, + ]}, + ]}, + + { title: 'Sections', base: '/api/sections', endpoints: [ + { method:'GET', path:'/api/sections', auth:'staff+', desc:'List all sections with their allowed options', + responses:[{ status:200, desc:'Success', body:[{ id:'sec-uuid-...', eventId:'ev-uuid-...', name:'Adults', allowedOptions:[{ eventOptionId:'opt-uuid-...', eventOption:{ name:'Adult', price:450 }}] }]}]}, + { method:'POST', path:'/api/sections', auth:'supervisor+', desc:'Create a section for an event', + request:{ body:{ eventId:'ev-uuid-...', name:'Adults', optionIds:['opt-uuid-1','opt-uuid-2'] }}, + responses:[{ status:201, desc:'Created', body:{ id:'sec-uuid-new', name:'Adults', eventId:'ev-uuid-...' }}]}, + { method:'PUT', path:'/api/sections/:id', auth:'supervisor+', desc:'Update a section', + pathParams:{ ':id':'Section UUID' }, + request:{ body:{ name:'Adults & Teens', optionIds:['opt-uuid-1','opt-uuid-2','opt-uuid-3'] }}, + responses:[{ status:200, desc:'Updated', body:{ id:'sec-uuid-...', name:'Adults & Teens' }}]}, + { method:'DELETE', path:'/api/sections/:id', auth:'admin', desc:'Delete a section', + pathParams:{ ':id':'Section UUID' }, + responses:[{ status:200, desc:'Deleted', body:{ message:'Section deleted.' }}]}, + ]}, + + { title: 'Banner', base: '/api/banner', endpoints: [ + { method:'GET', path:'/api/banner', auth:'public', desc:'Get the current site-wide announcement banner', + responses:[ + { status:200, desc:'Banner set', body:{ message:'Registration for Camp 2025 is now open!', type:'info', active:true }}, + { status:200, desc:'No banner', body:{ message:null, active:false }}, + ]}, + { method:'POST', path:'/api/banner', auth:'supervisor+', desc:'Set or update the site banner', + request:{ body:{ message:'Registration is now closed.', type:'warning', active:true }}, + responses:[{ status:200, desc:'Saved', body:{ message:'Registration is now closed.', type:'warning', active:true }}]}, + ]}, + + { title: 'Forms', base: '/api/forms', endpoints: [ + { method:'GET', path:'/api/forms/responses', auth:'staff+', desc:'List all submitted form responses across all events', + queryParams:{ eventId:'Filter by event UUID', page:'Page (default 1)', limit:'Per page (default 20)' }, + responses:[{ status:200, desc:'Success', body:{ data:[{ id:'resp-uuid-...', registrationId:'reg-uuid-...', createdAt:'2025-06-01T10:05:00.000Z', answers:[{ field:{ label:'Dietary requirements' }, value:'Vegetarian' }] }], total:1 }}]}, + ]}, + + { title: 'Yoco Transactions', base: '/api/yoco-transactions', endpoints: [ + { method:'GET', path:'/api/yoco-transactions', auth:'supervisor+', desc:'All Yoco webhook transactions (raw events received from Yoco)', + queryParams:{ page:'Page (default 1)', limit:'Per page (default 50)', reconciled:'true|false' }, + responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }], total:1 }}]}, + { method:'GET', path:'/api/yoco-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record', + responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }], total:1 }}]}, + { method:'POST', path:'/api/yoco-transactions/:id/reconcile', auth:'supervisor+', desc:'Manually link a Yoco transaction to an existing payment', + pathParams:{ ':id':'YocoTransaction UUID' }, + request:{ body:{ paymentId:'pay-uuid-...' }}, + responses:[{ status:200, desc:'Reconciled', body:{ id:'ytx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }}]}, + { method:'POST', path:'/api/yoco-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored', + pathParams:{ ':id':'YocoTransaction UUID' }, + responses:[{ status:200, desc:'Ignored', body:{ id:'ytx-uuid-...', ignored:true }}]}, + ]}, + + { title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [ + { method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)', + responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]}, + { method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.', + request:{ body:{ accessToken:'eyJhbGciOiJSUzI1...', instanceId:'wawp-instance-abc' }}, + responses:[{ status:200, desc:'Saved', body:{ message:'WhatsApp config saved.' }}]}, + { method:'GET', path:'/api/whatsapp/status', auth:'admin', desc:'Get the current WAWP session status', + responses:[ + { status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Hope Events' }}, + { status:200, desc:'Not connected', body:{ status:'close' }}, + ]}, + { method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account', + responses:[{ status:200, desc:'Success', body:{ qr:'data:image/png;base64,...', qrString:'2@abc123...' }}]}, + { method:'POST', path:'/api/whatsapp/create-instance', auth:'admin', desc:'Create a new WAWP session instance', + responses:[{ status:200, desc:'Created', body:{ instanceId:'wawp-instance-new', message:'Instance created.' }}]}, + { method:'POST', path:'/api/whatsapp/restart', auth:'admin', desc:'Restart the current WAWP instance', + responses:[{ status:200, desc:'Restarted', body:{ message:'Instance restarted.' }}]}, + { method:'POST', path:'/api/whatsapp/logout', auth:'admin', desc:'Log out the current WhatsApp session', + responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]}, + ]}, + + { title: 'Webhooks', base: '/api/webhooks', endpoints: [ + { method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.', + notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.', + request:{ body:{ id:'evt_yoco_abc', type:'payment.succeeded', payload:{ metadata:{ checkoutId:'checkout-id-...' }, amount:45000, currency:'ZAR' }}}, + responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]}, + { method:'POST', path:'/api/webhooks/whatsapp', auth:'—', desc:'WAWP inbound message event. Called by WAWP servers when a message arrives on the linked number.', + responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]}, + ]}, + + { title: 'Settings', base: '/api/settings', endpoints: [ + { method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, accent colour, logo URL, legal page slugs, registration notification email (no secrets)', + responses:[{ status:200, desc:'Success', body:{ org_name:'Hope Family Church', org_tagline:'Where everyone belongs', accent_color:'#2563eb', logo_url:'/uploads/logo.png' }}]}, + { method:'GET', path:'/api/settings/all', auth:'admin', desc:'All settings including SMTP config. smtp_pass is returned masked (••••••••); smtp_user is returned decrypted.', + responses:[{ status:200, desc:'Success', body:[{ key:'smtp_host', value:'smtp.example.com' },{ key:'smtp_user', value:'user@example.com' },{ key:'smtp_pass', value:'••••••••' }]}]}, + { method:'GET', path:'/api/settings/needs-setup', auth:'public', desc:'Returns true until the setup wizard has been completed (setup_complete flag is set).', + responses:[{ status:200, desc:'Success', body:{ needsSetup:true }}]}, + { method:'PUT', path:'/api/settings', auth:'admin', desc:'Upsert one or more settings. smtp_user and smtp_pass are AES-256-GCM encrypted before storage. Sending •••••••• for smtp_pass is a no-op.', + request:{ body:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }}, + responses:[{ status:200, desc:'Saved', body:{ message:'Settings saved.' }}]}, + { method:'POST', path:'/api/settings/test-smtp', auth:'admin (or setup token)', desc:'Test the SMTP connection with provided credentials. On success, sends a real test email to the authenticated admin and returns a friendly message. On failure, returns a human-readable message plus a raw field containing the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.', + request:{ body:{ host:'smtp.gmail.com', port:587, secure:false, user:'me@gmail.com', pass:'app-password', from:'me@gmail.com' }}, + responses:[ + { status:200, desc:'Connection OK — test email sent', body:{ message:'SMTP connection verified — a test email has been sent to admin@example.com' }}, + { status:400, desc:'Connection failed', body:{ message:'Authentication failed — check your SMTP username and password.', raw:'535 5.7.8 Error: authentication failed' }}, + ]}, + ]}, + + { title: 'Setup', base: '/api/setup', endpoints: [ + { method:'POST', path:'/api/setup/register', auth:'public (one-time)', desc:'Step 2 of the first-time setup wizard. Creates the admin account and returns a short-lived JWT. Blocked once any user exists.', + request:{ body:{ name:'Admin User', email:'admin@example.com', password:'strong-password' }}, + responses:[ + { status:201, desc:'Created', body:{ token:'eyJhbGciOiJIUzI1NiJ9...', user:{ id:'uuid-...', name:'Admin User', role:'admin' }}}, + { status:400, desc:'Already exists', body:{ message:'Setup already completed — users exist.' }}, + ]}, + { method:'POST', path:'/api/setup', auth:'setup token or admin', desc:'Final step of the setup wizard. Saves initial site settings. Requires the JWT returned by POST /api/setup/register.', + request:{ body:{ settings:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}}, + responses:[{ status:200, desc:'Setup complete', body:{ message:'Setup complete.' }}]}, + ]}, + ]; + + // ── Rendering helpers ───────────────────────────────────────────────────── + function hl(json) { + // Simple JSON syntax highlighting — server-side + const s = JSON.stringify(json, null, 2); + return s + .replace(/&/g,'&').replace(//g,'>') + .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (m) => { + if (/^"/.test(m)) { + if (/:$/.test(m)) return `${m}`; // key + return `${m}`; // string value + } + if (/true|false/.test(m)) return `${m}`; + if (/null/.test(m)) return `${m}`; + return `${m}`; // number + }); + } + + function codeBlock(content, lang) { + return `
${content}
`; + } + + function headerLine(key, val) { + return `${key}: ${val}`; + } + + function renderEndpoint(ep, idx) { + const id = `ep${idx}`; + const methodCls = { GET:'get', POST:'post', PUT:'put', PATCH:'patch', DELETE:'del' }[ep.method] || 'get'; + const hasDetail = ep.notes || ep.pathParams || ep.queryParams || ep.request || ep.responses; + + // Build HTTP request example + let reqLines = []; + const needsAuth = ep.auth !== 'public' && ep.auth !== 'HMAC' && ep.auth !== '—'; + const isMultipart = ep.notes && ep.notes.includes('multipart'); + + reqLines.push(`${ep.method} ${ep.path} HTTP/1.1`); + if (needsAuth) reqLines.push(headerLine('Authorization','Bearer <your-jwt>')); + if (ep.request?.body && !isMultipart) reqLines.push(headerLine('Content-Type','application/json')); + if (isMultipart) reqLines.push(headerLine('Content-Type','multipart/form-data')); + if (ep.request?.body && !isMultipart) { + reqLines.push(''); + reqLines.push(hl(ep.request.body)); + } else if (isMultipart) { + reqLines.push(''); + reqLines.push(`-- form field: image (binary file) --`); + } + + const reqHtml = codeBlock(reqLines.join('\n')); + + // Build response examples + const resHtml = (ep.responses || []).map(r => { + const statusColor = r.status < 300 ? '#22c55e' : r.status < 500 ? '#f59e0b' : '#ef4444'; + const body = typeof r.body === 'string' ? `${r.body}` : hl(r.body); + return `
+
+ HTTP ${r.status} + ${r.desc} +
+ ${codeBlock(body)} +
`; + }).join(''); + + // Path params table + let paramsHtml = ''; + if (ep.pathParams && Object.keys(ep.pathParams).length) { + const rows = Object.entries(ep.pathParams).map(([k,v]) => + `${k}${v}`).join(''); + paramsHtml += `
Path parameters
${rows}
`; + } + if (ep.queryParams && Object.keys(ep.queryParams).length) { + const rows = Object.entries(ep.queryParams).map(([k,v]) => + `${k}${v}`).join(''); + paramsHtml += `
Query parameters
${rows}
`; + } + + const notesHtml = ep.notes + ? `
${ep.notes}
` + : ''; + + const detailHtml = hasDetail ? ` + + +
+ ${notesHtml} + ${paramsHtml} +
+
+
Request
+ ${reqHtml} +
+
+
Responses
+ ${resHtml} +
+
+
+ + ` : ''; + + const cursor = hasDetail ? 'cursor:pointer' : ''; + const chevron = hasDetail + ? `` + : ''; + + const onclick = hasDetail ? `onclick="toggle('${id}')"` : ''; + + return ` + + ${ep.method} + ${ep.path} + ${ep.auth} + + ${ep.desc} + ${chevron} + + + ${detailHtml}`; + } + + function renderGroup(group) { + const rows = group.endpoints.map((ep, i) => renderEndpoint(ep, `${group.title.replace(/\W+/g,'')}-${i}`)).join(''); + return ` +

${group.title} ${group.base}

+
+ + + + + + + + ${rows} +
MethodPathAuthDescription
+
`; + } + + const groupsHtml = GROUPS.map(renderGroup).join(''); + + // ── Notifications reference — every automatic email/WhatsApp message the system sends ── + const NOTIFICATIONS = [ + { category: 'Registration', items: [ + { trigger: 'New registration — self-service (attendee registers via the public website)', channels: ['Email', 'WhatsApp'], recipients: 'Registrant', subject: 'Registration confirmed – {event}', content: 'Selections table, total due / paid / balance, payment options (website or at the door), and an account login / create-account prompt.' }, + { trigger: 'New registration — self-service', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator when none are set)', subject: 'New registration: {event} — {name}', content: 'Registrant name, email, phone, status, items table, total due / paid / balance, registration ID.' }, + { trigger: 'New registration — staff/at-door (manual registration, self-service kiosk)', channels: ['Email', 'WhatsApp'], recipients: 'Registrant', subject: 'Registration confirmed – {event}', content: 'Same as above, plus a Yoco pay-now link when a balance is owing.' }, + { trigger: 'New registration — staff/at-door', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'New registration: {event} — {name}', content: 'Same layout as the self-service admin notice.' }, + { trigger: 'Registration updated (options changed by staff, or a free registration completes)', channels: ['Email', 'WhatsApp'], recipients: 'Registrant', subject: 'Registration updated – {event}', content: 'Updated selections/total, same payment & account sections as the confirmation email.' }, + { trigger: 'Registration updated', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Registration updated: {event} — {name}', content: 'Same layout as the new-registration admin notice.' }, + ]}, + { category: 'Payments', items: [ + { trigger: 'Payment recorded (Yoco webhook success, manual cash/EFT entry, or a donation assigned to a registration)', channels: ['Email', 'WhatsApp'], recipients: 'Payer', subject: 'Payment received – {event} (or "Donation received – {event}" for unassigned donations)', content: 'Amount, method, date, full payment history table, updated balance.' }, + { trigger: 'Payment recorded', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Payment recorded: {amount} — {payer} ({event})', content: 'Amount, type (registration payment / donation), payer, event, method, date, payment ID, external (Yoco) ID.' }, + { trigger: 'Refund processed (negative payment created against an original payment)', channels: ['Email'], recipients: 'Payer', subject: 'Refund processed – {amount} for {event}', content: 'Refund amount, method, date, reason (when supplied).' }, + { trigger: 'Refund processed', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Refund: Payment recorded: {amount} — {payer} ({event})', content: 'Same payment admin notice content, subject prefixed "Refund:".' }, + ]}, + { category: 'Tickets', items: [ + { trigger: 'Tickets generated — fires whenever a registration becomes fully paid or is free (webhook, manual payment, staff status change, or free/no-cost registration)', channels: ['Email (PDF attached)', 'WhatsApp (PDF)'], recipients: 'Registrant (or a specific user when sent manually via "Send to")', subject: 'Your tickets for {event}', content: 'QR-coded ticket PDF, one per registered option/quantity; WhatsApp caption names the event and date.' }, + ]}, + { category: 'Daily digest', items: [ + { trigger: 'Scheduled — every day at 07:00 local server time, once per active event that has gone live and has not yet started', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Daily summary: {event} — {date}', content: 'Stat tiles (registrations, paid, awaiting payment, revenue), full registrations table with balances, full payments & donations table.' }, + ]}, + { category: 'Account & security', items: [ + { trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to Hope Events!', content: 'Welcome message plus a list of upcoming events.' }, + { trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your Hope Events account', content: 'One-time activation link; expires after 24 hours.' }, + { trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your Hope Events account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' }, + { trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your Hope Events password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' }, + { trigger: 'Forgot-password request', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Reset your password', content: 'Password reset link.' }, + { trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your Hope Events account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' }, + ]}, + { category: 'Admin-triggered broadcasts', items: [ + { trigger: 'Bulk email to event attendees, or an ad-hoc email broadcast — sent immediately or on a schedule', channels: ['Email'], recipients: 'Selected attendees / users / ad-hoc addresses chosen by the sender', subject: 'Admin-authored', content: 'Free-form subject & body written by the sender, supporting {{name}}, {{event.title}}, {{event.link}} placeholders. Used for reminders, thank-yous, promos, and multi-step automations.' }, + { trigger: 'Bulk WhatsApp to event attendees, or an ad-hoc WhatsApp broadcast — sent immediately or on a schedule', channels: ['WhatsApp'], recipients: 'Selected attendees / users / ad-hoc phone numbers chosen by the sender', subject: '—', content: 'Free-form message written by the sender, same placeholder support as email broadcasts.' }, + ]}, + ]; + + function notifChannelBadges(channels) { + return channels.map(c => { + const isWa = /whatsapp/i.test(c); + return `${c}`; + }).join(''); + } + + function renderNotificationRow(n) { + return ` + ${n.trigger} + ${notifChannelBadges(n.channels)} + ${n.recipients} + ${n.subject} + ${n.content} + `; + } + + function renderNotificationCategory(cat) { + const rows = cat.items.map(renderNotificationRow).join(''); + return ` +

${cat.category}

+
+ + + + + + + + + ${rows} +
Trigger — when it's sentChannelRecipientsSubjectContent
+
`; + } + + const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join(''); + + const html = ` + + + + + Hope Events — API Docs + + + +
+
+

Hope Events — API Reference

+ ← Status page +
+

+ Logged in as ${user.name || user.email} (${user.role}) — + Click any row to expand request & response examples. +

+ +
+ Protected routes require Authorization: Bearer <jwt>  |  + Roles: admin > supervisor > staff > user  |  + Base URL: ${process.env.APP_BASE_URL || 'http://localhost:' + PORT} +
+ + ${groupsHtml} + +

Notifications sent by the system

+

+ Every automatic email and WhatsApp message the platform sends, what triggers it, who receives it, and what it contains. + Admin-facing registration/payment/daily-summary notices go to the reg_notification_emails setting plus each + event's configured notify recipients (Admin → Events → edit event → Notifications step) — + falling back to that event's creator when no recipients have been chosen. +

+ + ${notificationsHtml} + +

+ Hope Events API v${API_VERSION} — ${new Date().toISOString()} +

+
+ + +`; + + res.send(html); +}); + +// Error middleware +app.use(notFound); +app.use(errorHandler); + +// Start server +app.listen(PORT, () => { + console.log(`Server is running at http://localhost:${PORT}`); + // Attempt a one-time background sync of attachment manifests into DB + try { + const { syncManifestsToDb } = require('./utils/attachmentsSync'); + const prismaShared = require('./config/db'); + setTimeout(async () => { + try { + const summary = await syncManifestsToDb(prismaShared, { dryRun: false }); + if ((summary.imported || 0) > 0) { + console.log(`[attachments sync] Imported ${summary.imported} attachments from manifests (skipped ${summary.skipped}).`); + } else { + console.log('[attachments sync] No attachments imported (either none found or already in DB).'); + } + } catch (e) { + console.warn('[attachments sync] Failed:', e?.message || e); + } + }, 1000); + } catch (e) { + console.warn('[attachments sync] Not scheduled:', e?.message || e); + } + + // Schedule daily summaries at 07:00 local time (configurable) + try { + const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false'; + if (enabled) { + const { sendDailyEventSummaries } = require('./utils/notifications'); + function msUntilNext(hour, minute) { + const now = new Date(); + const next = new Date(now); + next.setHours(hour, minute, 0, 0); + if (next <= now) { + next.setDate(next.getDate() + 1); + } + return next.getTime() - now.getTime(); + } + async function scheduleNext() { + const delay = msUntilNext(7, 0); + setTimeout(async () => { + try { + console.log('[daily summaries] Running daily event summaries...'); + await sendDailyEventSummaries(new Date()); + console.log('[daily summaries] Completed sending daily event summaries.'); + } catch (e) { + console.error('[daily summaries] Failed:', e?.message || e); + } finally { + // Schedule next run + scheduleNext(); + } + }, delay); + } + scheduleNext(); + console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.'); + } else { + console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false'); + } + } catch (e) { + console.warn('[daily summaries] Not scheduled:', e?.message || e); + } + + // Daily temp-file cleanup — removes files older than 7 days from /temp + try { + const { cleanupTempFiles } = require('./utils/cleanupTemp'); + function scheduleTempCleanup() { + const now = new Date(); + const next = new Date(now); + next.setHours(3, 0, 0, 0); // Run at 03:00 local time (low-traffic window) + if (next <= now) next.setDate(next.getDate() + 1); + setTimeout(() => { + try { + const result = cleanupTempFiles(); + if (result.deleted > 0 || result.errors > 0) { + console.log(`[temp cleanup] Deleted ${result.deleted} file(s), ${result.errors} error(s).`); + } + } catch (e) { + console.warn('[temp cleanup] Failed:', e?.message || e); + } finally { + scheduleTempCleanup(); + } + }, next.getTime() - now.getTime()); + } + scheduleTempCleanup(); + console.log('[temp cleanup] Scheduler initialized (03:00 local time, files older than 7 days).'); + } catch (e) { + console.warn('[temp cleanup] Not scheduled:', e?.message || e); + } + + // Scheduled emails worker (polling) + try { + const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false'; + if (enabled) { + const { getDueJobs, updateJob } = require('./utils/scheduledEmails'); + const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController'); + const { sendBroadcast } = require('./controllers/broadcastController'); + const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController'); + const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10); + setInterval(async () => { + try { + const due = getDueJobs(new Date()); + if (!due || due.length === 0) return; + for (const job of due) { + try { + updateJob(job.id, { status: 'sending', attempts: (job.attempts || 0) + 1, lastError: null }); + let responseBody = null; let code = 200; + const mockRes = { + status: (c) => { code = c; return mockRes; }, + json: (b) => { responseBody = b; return mockRes; } + }; + if (job.broadcast && job.channel === 'whatsapp') { + const mockReq = { body: job.payload }; + await sendWhatsAppBroadcast(mockReq, mockRes); + } else if (job.broadcast) { + const mockReq = { body: job.payload }; + await sendBroadcast(mockReq, mockRes); + } else if (job.channel === 'whatsapp') { + const mockReq = { params: { id: job.eventId }, body: job.payload }; + await whatsappEventAttendees(mockReq, mockRes); + } else { + const mockReq = { params: { id: job.eventId }, body: job.payload }; + await emailEventAttendees(mockReq, mockRes); + } + if (code >= 200 && code < 300) { + updateJob(job.id, { status: 'sent', sentAt: new Date().toISOString(), lastResult: responseBody || null }); + } else { + updateJob(job.id, { status: 'error', lastError: (responseBody && responseBody.message) ? responseBody.message : `HTTP ${code}` }); + } + } catch (e) { + updateJob(job.id, { status: 'error', lastError: String(e?.message || e) }); + } + } + } catch (e) { + console.warn('[scheduled emails] poll failed:', e?.message || e); + } + }, isNaN(intervalMs) ? 30000 : intervalMs); + console.log('[scheduled emails] Worker initialized. Set SCHEDULED_EMAILS_ENABLED=false to disable.'); + } else { + console.log('[scheduled emails] Worker disabled by env SCHEDULED_EMAILS_ENABLED=false'); + } + } catch (e) { + console.warn('[scheduled emails] Worker not started:', e?.message || e); + } +}); + +// Handle unhandled promise rejections +process.on('unhandledRejection', (err) => { + console.log('UNHANDLED REJECTION! Shutting down...'); + console.log(err.name, err.message); + process.exit(1); +}); + +module.exports = { app, prisma }; \ No newline at end of file diff --git a/backend/src/middleware/authMiddleware.js b/backend/src/middleware/authMiddleware.js new file mode 100644 index 0000000..e2058a1 --- /dev/null +++ b/backend/src/middleware/authMiddleware.js @@ -0,0 +1,122 @@ +const rateLimit = require("express-rate-limit"); + +const jwt = require('jsonwebtoken'); +const prisma = require('../config/db'); + +// Protect routes - verify token +const protect = async (req, res, next) => { + let token; + + // Check if token exists in headers + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { + try { + // Get token from header + token = req.headers.authorization.split(' ')[1]; + + // Verify token + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Get user from the token (exclude password) + req.user = await prisma.user.findUnique({ + where: { id: decoded.id }, + select: { + id: true, + name: true, + email: true, + role: true, + isActive: true, + createdAt: true, + updatedAt: true, + phoneNumber: true, + tokenVersion: true + } + }); + + if (!req.user) { + res.status(401); + return next(new Error('User not found')); + } + + if (!req.user.isActive) { + res.status(401); + return next(new Error('User account is deactivated')); + } + + // Revocation check — tokenVersion in JWT must match DB + // Old tokens without tokenVersion are treated as version 0 + const tokenVer = decoded.tokenVersion ?? 0; + if (tokenVer !== req.user.tokenVersion) { + res.status(401); + return next(new Error('Session has been revoked. Please log in again.')); + } + + next(); + } catch (error) { + console.error(error); + res.status(401); + return next(new Error('Not authorized, token failed')); + } + } else { + res.status(401); + return next(new Error('Not authorized, no token')); + } +}; + +// Admin only middleware +const admin = (req, res, next) => { + if (req.user && req.user.role === 'admin') { + next(); + } else { + res.status(403); + return next(new Error('Not authorized as an admin')); + } +}; + +// Staff or higher middleware +const staff = (req, res, next) => { + if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor' || req.user.role === 'staff')) { + next(); + } else { + res.status(403); + return next(new Error('Not authorized as staff')); + } +}; + +// Supervisor or higher middleware +const supervisor = (req, res, next) => { + if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor')) { + next(); + } else { + res.status(403); + return next(new Error('Not authorized as a supervisor')); + } +}; + +const loginLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 15, + message: "Too many login attempts. Try again later.", +}); + +// Optional auth — populates req.user if a valid token is present, but never rejects the request +const optionalAuth = async (req, res, next) => { + if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer')) { + return next(); + } + try { + const token = req.headers.authorization.split(' ')[1]; + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const user = await prisma.user.findUnique({ + where: { id: decoded.id }, + select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true } + }); + if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) { + req.user = user; + } + } catch { + // Token invalid or expired — proceed without user + } + next(); +}; + +module.exports = { protect, admin, staff, supervisor, loginLimiter, optionalAuth }; \ No newline at end of file diff --git a/backend/src/middleware/errorMiddleware.js b/backend/src/middleware/errorMiddleware.js new file mode 100644 index 0000000..e02d654 --- /dev/null +++ b/backend/src/middleware/errorMiddleware.js @@ -0,0 +1,20 @@ +const { safeErrorMessage } = require('../utils/errorUtils'); + +// Not found error handler — don't echo the URL back (leaks route structure) +const notFound = (req, res, next) => { + const error = new Error('Not Found'); + res.status(404); + next(error); +}; + +// General error handler +const errorHandler = (err, req, res, next) => { + const statusCode = res.statusCode === 200 ? 500 : res.statusCode; + + res.status(statusCode).json({ + message: safeErrorMessage(err), + stack: process.env.NODE_ENV === 'production' ? undefined : err.stack, + }); +}; + +module.exports = { notFound, errorHandler }; \ No newline at end of file diff --git a/backend/src/routes/automationRoutes.js b/backend/src/routes/automationRoutes.js new file mode 100644 index 0000000..8755480 --- /dev/null +++ b/backend/src/routes/automationRoutes.js @@ -0,0 +1,9 @@ +const express = require('express'); +const router = express.Router(); +const { scheduleAutomations } = require('../controllers/automationController'); +const { protect, supervisor } = require('../middleware/authMiddleware'); + +// Schedule multiple automation emails for an event +router.post('/schedule', protect, supervisor, scheduleAutomations); + +module.exports = router; diff --git a/backend/src/routes/bannerRoutes.js b/backend/src/routes/bannerRoutes.js new file mode 100644 index 0000000..8a2fabe --- /dev/null +++ b/backend/src/routes/bannerRoutes.js @@ -0,0 +1,9 @@ +const express = require('express'); +const router = express.Router(); +const { getBanner, setBanner } = require('../controllers/bannerController'); +const { protect, supervisor } = require('../middleware/authMiddleware'); + +router.get('/', getBanner); +router.post('/', protect, supervisor, setBanner); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/broadcastRoutes.js b/backend/src/routes/broadcastRoutes.js new file mode 100644 index 0000000..f9a4491 --- /dev/null +++ b/backend/src/routes/broadcastRoutes.js @@ -0,0 +1,11 @@ +const express = require('express'); +const router = express.Router(); +const { previewBroadcast, sendBroadcast, scheduleBroadcast } = require('../controllers/broadcastController'); +const { protect, supervisor } = require('../middleware/authMiddleware'); + +// All routes require supervisor (or admin via middleware logic) access +router.post('/preview', protect, supervisor, previewBroadcast); +router.post('/send', protect, supervisor, sendBroadcast); +router.post('/schedule', protect, supervisor, scheduleBroadcast); + +module.exports = router; diff --git a/backend/src/routes/cashupRoutes.js b/backend/src/routes/cashupRoutes.js new file mode 100644 index 0000000..961dbfd --- /dev/null +++ b/backend/src/routes/cashupRoutes.js @@ -0,0 +1,12 @@ +const express = require('express'); +const router = express.Router(); +const { getEventCashup, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController'); +const { protect, supervisor, admin } = require('../middleware/authMiddleware'); + +router.get('/audit', protect, supervisor, getCashupAudit); +router.get('/event/:eventId', protect, supervisor, getEventCashup); +router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft); +router.post('/event/:eventId/close', protect, admin, closeEvent); +router.post('/event/:eventId/reopen', protect, admin, reopenEvent); + +module.exports = router; diff --git a/backend/src/routes/costRoutes.js b/backend/src/routes/costRoutes.js new file mode 100644 index 0000000..c8b1784 --- /dev/null +++ b/backend/src/routes/costRoutes.js @@ -0,0 +1,12 @@ +const express = require('express'); +const router = express.Router(); +const { getEventCosts, createEventCost, updateEventCost, deleteEventCost } = require('../controllers/costController'); +const { protect, supervisor, admin } = require('../middleware/authMiddleware'); + +// Mounted at /api — paths below match the documented /api/events/:eventId/costs and /api/costs/:id shape +router.get('/events/:eventId/costs', protect, supervisor, getEventCosts); +router.post('/events/:eventId/costs', protect, admin, createEventCost); +router.put('/costs/:id', protect, admin, updateEventCost); +router.delete('/costs/:id', protect, admin, deleteEventCost); + +module.exports = router; diff --git a/backend/src/routes/eventRoutes.js b/backend/src/routes/eventRoutes.js new file mode 100644 index 0000000..2bc408f --- /dev/null +++ b/backend/src/routes/eventRoutes.js @@ -0,0 +1,86 @@ +const express = require('express'); +const router = express.Router(); +const { + createEvent, + getEvents, + getAllEvents, + getEventsAll, + getEventById, + updateEvent, + getEventNotifyRecipients, + updateEventNotifyRecipients, + deleteEvent, + createEventOption, + updateEventOption, + deleteEventOption, + createOptionVariant, + updateOptionVariant, + deleteOptionVariant, + listEventAttachments, + uploadEventAttachment, + deleteEventAttachment, + attachmentsStatus, + attachmentsSync, + emailEventAttendees, + scheduleEmailEventAttendees, + whatsappEventAttendees, + scheduleWhatsappEventAttendees, + getEventByAlias +} = require('../controllers/eventController'); +const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware'); + +// Public routes +router.get('/', getEvents); + +// Staff/supervisor/admin: all events including hidden, optional past/inactive filters +router.get('/all', protect, getEventsAll); + +// Admin routes +router.get('/admin/all', protect, admin, getAllEvents); +router.get('/attachments/status', protect, admin, attachmentsStatus); +router.post('/attachments/sync', protect, admin, attachmentsSync); + +// Public route for single event (keep after specific admin routes). +// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin +// can still load inactive events (e.g. for cashup or editing) without being 404'd. +router.get('/:id', optionalAuth, getEventById); +router.get('/by-alias/:redirectUrl', getEventByAlias); + +// Create/Update/Delete event +router.post('/', protect, supervisor, createEvent); +router.route('/:id') + .put(protect, supervisor, updateEvent) + .delete(protect, admin, deleteEvent); + +// Notification recipients: who gets registration/payment/daily-summary emails for this event +router.route('/:id/notify-recipients') + .get(protect, supervisor, getEventNotifyRecipients) + .put(protect, supervisor, updateEventNotifyRecipients); + +// Attachments routes +router.get('/:id/attachments', listEventAttachments); // public read (used by event detail page) +router.post('/:id/attachments', protect, supervisor, ...uploadEventAttachment); +router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, deleteEventAttachment); + +// Bulk email attendees +router.post('/:id/email-attendees', protect, supervisor, emailEventAttendees); +// Schedule bulk email to attendees +router.post('/:id/email-attendees/schedule', protect, supervisor, scheduleEmailEventAttendees); +// Bulk WhatsApp message to attendees +router.post('/:id/whatsapp-attendees', protect, supervisor, whatsappEventAttendees); +// Schedule bulk WhatsApp message to attendees +router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scheduleWhatsappEventAttendees); + +// Event options routes +router.post('/:id/options', protect, supervisor, createEventOption); +router.route('/options/:id') + .put(protect, supervisor, updateEventOption) + .delete(protect, admin, deleteEventOption); + +// Option variant routes +router.post('/options/:id/variants', protect, supervisor, createOptionVariant); +router.route('/variants/:id') + .put(protect, supervisor, updateOptionVariant) + .delete(protect, admin, deleteOptionVariant); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/formRoutes.js b/backend/src/routes/formRoutes.js new file mode 100644 index 0000000..7d6f1d1 --- /dev/null +++ b/backend/src/routes/formRoutes.js @@ -0,0 +1,9 @@ +const express = require('express'); +const router = express.Router(); +const { listFormResponses } = require('../controllers/formController'); +const { protect, staff } = require('../middleware/authMiddleware'); + +// Staff or higher can view submitted forms +router.get('/responses', protect, staff, listFormResponses); + +module.exports = router; diff --git a/backend/src/routes/paymentRoutes.js b/backend/src/routes/paymentRoutes.js new file mode 100644 index 0000000..1774b47 --- /dev/null +++ b/backend/src/routes/paymentRoutes.js @@ -0,0 +1,33 @@ +const express = require('express'); +const router = express.Router(); +const { + createPayment, + getPayments, + getUserPayments, + getPaymentById, + getPaymentsByRegistration, + getPaymentsByEvent, + assignDonationToRegistration, + createYocoCheckout, + sendPaymentLink, + createRefund, + getPaymentStats +} = require('../controllers/paymentController'); +const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware'); + +// Protected routes +router.post('/', protect, supervisor, createPayment); +router.post('/yoco-checkout', protect, createYocoCheckout); +router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink); +router.get('/mypayments', protect, getUserPayments); +router.get('/:id', protect, getPaymentById); +router.get('/registration/:registrationId', protect, getPaymentsByRegistration); + +// Admin/Staff routes +router.get('/', protect, supervisor, getPayments); +router.get('/event/:eventId', protect, staff, getPaymentsByEvent); +router.put('/assign-donation', protect, supervisor, assignDonationToRegistration); +router.post('/refund', protect, supervisor, createRefund); +router.get('/admin/stats', protect, admin, getPaymentStats); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/registrationRoutes.js b/backend/src/routes/registrationRoutes.js new file mode 100644 index 0000000..a887ce1 --- /dev/null +++ b/backend/src/routes/registrationRoutes.js @@ -0,0 +1,37 @@ +const express = require('express'); +const router = express.Router(); +const { + createRegistration, + getRegistrations, + getUserRegistrations, + getRegistrationById, + updateRegistrationStatus, + cancelRegistration, + getRegistrationsByEvent, + createManualRegistration, + updateRegistrationOptions, + submitFormResponses, replaceFormResponses, + getFormDraft, saveFormDraft, +} = require('../controllers/registrationController'); +const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware'); + +// Registration - auth optional when event doesn't require it +router.post('/', optionalAuth, createRegistration); +router.get('/myregistrations', protect, getUserRegistrations); +router.put('/:id/options', protect, updateRegistrationOptions); +router.delete('/:id', protect, cancelRegistration); + +// Registration detail + forms — optionalAuth so guests can access with just the registrationId +router.get('/:id', optionalAuth, getRegistrationById); +router.get('/:id/forms/draft', optionalAuth, getFormDraft); +router.put('/:id/forms/draft', optionalAuth, saveFormDraft); +router.post('/:id/forms/responses', optionalAuth, submitFormResponses); +router.put('/:id/forms/responses', protect, supervisor, replaceFormResponses); + +// Admin/Staff routes +router.get('/', protect, staff, getRegistrations); +router.put('/:id', protect, staff, updateRegistrationStatus); +router.get('/event/:eventId', protect, staff, getRegistrationsByEvent); +router.post('/manual', protect, supervisor, createManualRegistration); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/reportRoutes.js b/backend/src/routes/reportRoutes.js new file mode 100644 index 0000000..bfc29e2 --- /dev/null +++ b/backend/src/routes/reportRoutes.js @@ -0,0 +1,12 @@ +const express = require('express'); +const router = express.Router(); +const { generatePdf, emailPdf } = require('../controllers/reportController'); +const { protect } = require('../middleware/authMiddleware'); + +// Generate and download PDF +router.post('/pdf', protect, generatePdf); + +// Email PDF to current user +router.post('/email', protect, emailPdf); + +module.exports = router; diff --git a/backend/src/routes/scheduledEmailRoutes.js b/backend/src/routes/scheduledEmailRoutes.js new file mode 100644 index 0000000..3c7bbaa --- /dev/null +++ b/backend/src/routes/scheduledEmailRoutes.js @@ -0,0 +1,11 @@ +const express = require('express'); +const router = express.Router(); +const { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail } = require('../controllers/scheduledEmailsController'); +const { protect, supervisor } = require('../middleware/authMiddleware'); + +// All routes require supervisor (or admin via middleware logic) access +router.get('/', protect, supervisor, listScheduledEmails); +router.patch('/:id', protect, supervisor, updateScheduledEmail); +router.delete('/:id', protect, supervisor, deleteScheduledEmail); + +module.exports = router; diff --git a/backend/src/routes/sectionRoutes.js b/backend/src/routes/sectionRoutes.js new file mode 100644 index 0000000..52df37f --- /dev/null +++ b/backend/src/routes/sectionRoutes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const router = express.Router(); +const { + getSections, + createSection, + deleteSection, + updateSection +} = require('../controllers/sectionController'); +const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware'); + +router.get('/', protect, staff, getSections); +router.post('/', protect, supervisor, createSection); +router.delete('/:id', protect, admin, deleteSection); +router.put('/:id', protect, supervisor, updateSection); + +module.exports = router; diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js new file mode 100644 index 0000000..10b2ce9 --- /dev/null +++ b/backend/src/routes/settingsRoutes.js @@ -0,0 +1,12 @@ +const express = require('express'); +const router = express.Router(); +const { getSettings, getAllSettings, updateSettings, needsSetup, testSmtp } = require('../controllers/settingsController'); +const { protect, admin } = require('../middleware/authMiddleware'); + +router.get('/needs-setup', needsSetup); +router.get('/all', protect, admin, getAllSettings); +router.get('/', getSettings); +router.put('/', protect, admin, updateSettings); +router.post('/test-smtp', protect, admin, testSmtp); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/setupRoutes.js b/backend/src/routes/setupRoutes.js new file mode 100644 index 0000000..b58bb53 --- /dev/null +++ b/backend/src/routes/setupRoutes.js @@ -0,0 +1,12 @@ +const express = require('express'); +const router = express.Router(); +const { setupRegister, runSetup } = require('../controllers/settingsController'); +const { protect, admin } = require('../middleware/authMiddleware'); + +// Step 1 of setup: create the admin account, returns a JWT token +router.post('/register', setupRegister); + +// Step 2 of setup: finalize settings (requires the token from /register) +router.post('/', protect, admin, runSetup); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/statsRoutes.js b/backend/src/routes/statsRoutes.js new file mode 100644 index 0000000..16cd881 --- /dev/null +++ b/backend/src/routes/statsRoutes.js @@ -0,0 +1,13 @@ +const express = require('express'); +const router = express.Router(); +const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats } = require('../controllers/statsController'); +const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware'); + +// One endpoint per dashboard — each returns exactly what that dashboard renders in a +// single response instead of the dashboard making several separate calls (and, previously, +// pulling full payments/events lists just to reduce them to a couple of numbers). +router.get('/staff', protect, staff, getStaffDashboardStats); +router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats); +router.get('/admin', protect, admin, getAdminDashboardStats); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/ticketRoutes.js b/backend/src/routes/ticketRoutes.js new file mode 100644 index 0000000..1a454e1 --- /dev/null +++ b/backend/src/routes/ticketRoutes.js @@ -0,0 +1,39 @@ +const express = require('express'); +const router = express.Router(); +const { + generateTickets, + getTickets, + getUserTickets, + getTicketById, + getTicketByQrCode, + getScanPreview, + scanTicket, + getTicketsByEvent, + markTicketsAsEmailSent, + emailTickets, + sendTicketsTo, + getRecentScans, + getScanStats +} = require('../controllers/ticketController'); +const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware'); + +// Protected routes +router.get('/mytickets', protect, getUserTickets); +router.get('/:id', protect, getTicketById); +router.post('/email', protect, emailTickets); + +// Staff routes +router.post('/send-to', protect, staff, sendTicketsTo); +router.get('/scan-preview/:qrCode', protect, staff, getScanPreview); +router.get('/qr/:qrCode', protect, staff, getTicketByQrCode); +router.post('/scan/:qrCode', protect, staff, scanTicket); +router.get('/event/:eventId', protect, staff, getTicketsByEvent); +router.get('/scans/recent', protect, staff, getRecentScans); +router.get('/scans/stats', protect, staff, getScanStats); + +// Admin routes +router.post('/generate', protect, supervisor, generateTickets); +router.get('/', protect, supervisor, getTickets); +router.put('/email-sent', protect, admin, markTicketsAsEmailSent); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/uploadRoutes.js b/backend/src/routes/uploadRoutes.js new file mode 100644 index 0000000..3978bf4 --- /dev/null +++ b/backend/src/routes/uploadRoutes.js @@ -0,0 +1,41 @@ +const express = require('express'); +const router = express.Router(); + +const { upload, uploadEventImage, uploadLogo, uploadLogoImage } = require('../controllers/uploadController'); +const { protect, supervisor, admin } = require('../middleware/authMiddleware'); +const prisma = require('../config/db'); + +// @route POST /api/uploads/event-image +// @desc Upload an image for an event +// @access Private (Supervisor+ recommended) +router.post('/event-image', protect, supervisor, (req, res, next) => { + upload.single('image')(req, res, (err) => { + if (err) { + req.multerError = err; + } + next(); + }); +}, uploadEventImage); + +// @route POST /api/uploads/logo +// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet) +// @access Admin or setup + +async function logoAccess(req, res, next) { + try { + const count = await prisma.user.count(); + if (count === 0) return next(); // first-time setup + return protect(req, res, () => admin(req, res, next)); + } catch { + return protect(req, res, () => admin(req, res, next)); + } +} + +router.post('/logo', logoAccess, (req, res, next) => { + uploadLogo.single('image')(req, res, (err) => { + if (err) req.multerError = err; + next(); + }); +}, uploadLogoImage); + +module.exports = router; diff --git a/backend/src/routes/userRoutes.js b/backend/src/routes/userRoutes.js new file mode 100644 index 0000000..1a9c84d --- /dev/null +++ b/backend/src/routes/userRoutes.js @@ -0,0 +1,52 @@ +const express = require('express'); +const router = express.Router(); +const { + registerUser, + loginUser, + getUserProfile, + updateUserProfile, + getUsers, + checkUserExists, + getUserById, + updateUser, + deleteUser, + anonymizeUser, + requestPasswordReset, + resetPassword, + activateAccount, + revokeMySession, + adminRevokeUserSessions, + closeAccount, +} = require('../controllers/userController'); +const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware'); + +// Public routes +router.post('/', registerUser); +router.post('/login', loginLimiter, loginUser); +router.post('/forgot', requestPasswordReset); +router.post('/reset', resetPassword); +router.post('/activate', activateAccount); + +// Protected routes +router.route('/profile') + .get(protect, getUserProfile) + .put(protect, updateUserProfile); + +router.post('/revoke-sessions', protect, revokeMySession); +router.post('/close-account', protect, closeAccount); + +// Admin routes +router.route('/') + .get(protect, supervisor, getUsers); + +router.get('/check-exists', protect, supervisor, checkUserExists); + +router.route('/:id') + .get(protect, admin, getUserById) + .put(protect, admin, updateUser) + .delete(protect, admin, deleteUser); + +router.post('/:id/revoke-sessions', protect, admin, adminRevokeUserSessions); +router.post('/:id/anonymize', protect, admin, anonymizeUser); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/webhookRoutes.js b/backend/src/routes/webhookRoutes.js new file mode 100644 index 0000000..1fe6da1 --- /dev/null +++ b/backend/src/routes/webhookRoutes.js @@ -0,0 +1,9 @@ +const express = require('express'); +const router = express.Router(); +const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController'); + +// Yoco webhook endpoint +router.post('/yoco', handleYocoWebhook); +router.post('/whatsapp', handleWhatsappWebhook); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/whatsappBroadcastRoutes.js b/backend/src/routes/whatsappBroadcastRoutes.js new file mode 100644 index 0000000..a6a043b --- /dev/null +++ b/backend/src/routes/whatsappBroadcastRoutes.js @@ -0,0 +1,10 @@ +const express = require('express'); +const router = express.Router(); +const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController'); +const { protect, supervisor } = require('../middleware/authMiddleware'); + +router.post('/preview', protect, supervisor, previewWhatsAppBroadcast); +router.post('/send', protect, supervisor, sendWhatsAppBroadcast); +router.post('/schedule', protect, supervisor, scheduleWhatsAppBroadcast); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/whatsappRoutes.js b/backend/src/routes/whatsappRoutes.js new file mode 100644 index 0000000..47bee9a --- /dev/null +++ b/backend/src/routes/whatsappRoutes.js @@ -0,0 +1,37 @@ +const express = require('express'); +const router = express.Router(); +const { + getConfigHandler, + saveConfigHandler, + createInstanceHandler, + deleteInstanceHandler, + getStatusHandler, + getQrHandler, + requestCodeHandler, + logoutHandler, + startHandler, + restartHandler, + handleWebhook, +} = require('../controllers/whatsappController'); +const { protect, admin } = require('../middleware/authMiddleware'); + +// Public webhook (called by WAWP servers) +router.post('/webhook', handleWebhook); + +// Admin-only: credentials config +router.get( '/config', protect, admin, getConfigHandler); +router.post('/config', protect, admin, saveConfigHandler); + +// Admin-only: instance lifecycle +router.post('/create-instance', protect, admin, createInstanceHandler); +router.post('/delete-instance', protect, admin, deleteInstanceHandler); + +// Admin-only: session management +router.get( '/status', protect, admin, getStatusHandler); +router.get( '/qr', protect, admin, getQrHandler); +router.post('/request-code', protect, admin, requestCodeHandler); +router.post('/logout', protect, admin, logoutHandler); +router.post('/start', protect, admin, startHandler); +router.post('/restart', protect, admin, restartHandler); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/yocoTransactionRoutes.js b/backend/src/routes/yocoTransactionRoutes.js new file mode 100644 index 0000000..a38ed62 --- /dev/null +++ b/backend/src/routes/yocoTransactionRoutes.js @@ -0,0 +1,18 @@ +const express = require('express'); +const router = express.Router(); +const { getAllYocoTransactions, getUnreconciledYocoTransactions, reconcileYocoTransaction, ignoreYocoTransaction } = require('../controllers/yocoTransactionsController'); +const { protect, supervisor} = require('../middleware/authMiddleware'); + +// GET all Yoco transactions (admin/supervisor) +router.get('/', protect, supervisor, getAllYocoTransactions); + +// GET all unreconciled Yoco transactions (admin/supervisor) +router.get('/unreconciled', protect, supervisor, getUnreconciledYocoTransactions); + +// POST reconcile a Yoco transaction +router.post('/:id/reconcile', protect, supervisor, reconcileYocoTransaction); + +// POST ignore a Yoco transaction +router.post('/:id/ignore', protect, supervisor, ignoreYocoTransaction); + +module.exports = router; diff --git a/backend/src/utils/attachmentsSync.js b/backend/src/utils/attachmentsSync.js new file mode 100644 index 0000000..465ef06 --- /dev/null +++ b/backend/src/utils/attachmentsSync.js @@ -0,0 +1,107 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Checks whether the Prisma client has the EventAttachment model and the table is queryable. + * @param {import('@prisma/client').PrismaClient} prisma + */ +async function canUseEventAttachment(prisma) { + const hasModel = !!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function'); + if (!hasModel) return { ok: false, reason: 'NO_MODEL' }; + try { + await prisma.eventAttachment.findFirst({}); + return { ok: true }; + } catch (e) { + return { ok: false, reason: 'QUERY_ERROR', error: String(e?.message || e) }; + } +} + +/** + * Reads all manifest files for event attachments and inserts missing rows into DB. + * It is idempotent: it will skip entries that already exist by id. If id lookup fails, + * it tries to avoid duplicates using a composite check (eventId + filename + size). + * + * @param {import('@prisma/client').PrismaClient} prisma + * @param {{ dryRun?: boolean, removeManifestAfterImport?: boolean }} [options] + * @returns {Promise<{processedFiles:number, imported:number, skipped:number, errors:Array<{file:string,error:string}>, details:Array<{file:string, imported:number, skipped:number}>}>} + */ +async function syncManifestsToDb(prisma, options = {}) { + const { dryRun = false, removeManifestAfterImport = false } = options; + const baseDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files'); + const result = { processedFiles: 0, imported: 0, skipped: 0, errors: [], details: [] }; + + const check = await canUseEventAttachment(prisma); + if (!check.ok) { + return { ...result, errors: [{ file: '*', error: `Attachments model not usable (${check.reason})${check.error ? ': ' + check.error : ''}` }] }; + } + + if (!fs.existsSync(baseDir)) return result; + const files = fs.readdirSync(baseDir).filter(f => f.endsWith('.attachments.json')); + + for (const file of files) { + const manifestPath = path.join(baseDir, file); + result.processedFiles += 1; + let list = []; + try { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + list = JSON.parse(raw) || []; + } catch (e) { + result.errors.push({ file, error: 'Failed to parse JSON: ' + String(e?.message || e) }); + continue; + } + + let imported = 0; + let skipped = 0; + + for (const entry of list) { + try { + // Check existing by id first + const existsById = entry?.id ? await prisma.eventAttachment.findUnique({ where: { id: entry.id } }) : null; + if (existsById) { skipped++; continue; } + + // Check using composite heuristic to avoid duplicates + const maybeExisting = await prisma.eventAttachment.findFirst({ + where: { + eventId: entry.eventId, + filename: entry.filename, + size: typeof entry.size === 'number' ? entry.size : undefined, + } + }); + if (maybeExisting) { skipped++; continue; } + + if (!dryRun) { + await prisma.eventAttachment.create({ + data: { + id: entry.id || undefined, + eventId: entry.eventId, + originalName: entry.originalName || entry.filename || 'file', + filename: entry.filename, + mimeType: entry.mimeType || 'application/octet-stream', + size: typeof entry.size === 'number' ? entry.size : 0, + url: entry.url, + createdAt: entry.createdAt ? new Date(entry.createdAt) : undefined, + } + }); + } + imported++; + } catch (e) { + result.errors.push({ file, error: 'Insert failed: ' + String(e?.message || e) }); + } + } + + result.imported += imported; + result.skipped += skipped; + result.details.push({ file, imported, skipped }); + + // Optionally remove manifest if all entries are in DB now + if (!dryRun && removeManifestAfterImport && imported > 0) { + try { + fs.unlinkSync(manifestPath); + } catch {} + } + } + + return result; +} + +module.exports = { syncManifestsToDb, canUseEventAttachment }; diff --git a/backend/src/utils/cashupUtils.js b/backend/src/utils/cashupUtils.js new file mode 100644 index 0000000..b08a92d --- /dev/null +++ b/backend/src/utils/cashupUtils.js @@ -0,0 +1,204 @@ +const prisma = require('../config/db'); + +const METHOD_BUCKETS = ['cash', 'card', 'eft']; +const ALL_METHODS = ['cash', 'card', 'eft', 'other']; + +// Standard South African Rand note/coin denominations used for the cash count grid. +const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1]; + +function emptyByMethod(fill = 0) { + return { cash: fill, card: fill, eft: fill, other: fill }; +} + +// Normalizes a free-text Payment.method into one of the fixed cashup buckets. +function bucketForMethod(method) { + const m = String(method || '').toLowerCase(); + if (m.includes('cash')) return 'cash'; + if (m.includes('eft')) return 'eft'; + if (m.includes('card') || m.includes('yoco')) return 'card'; + return 'other'; +} + +// Throws if the event is closed. Callers wrap this in their existing try/catch +// (res.statusCode is set before throwing, matching the rest of the controllers). +async function assertEventOpen(eventId, res) { + const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, cashupStatus: true } }); + if (!event) { + if (res) res.status(404); + throw new Error('Event not found'); + } + if (event.cashupStatus === 'closed') { + if (res) res.status(400); + throw new Error('This event is closed. Reopen it (admin only) before making changes.'); + } + return event; +} + +// Same check, but resolves the event via a registrationId first (for payment/registration flows +// that receive a registrationId rather than an eventId directly). +async function assertRegistrationEventOpen(registrationId, res) { + const registration = await prisma.registration.findUnique({ where: { id: registrationId }, select: { eventId: true } }); + if (!registration) { + if (res) res.status(404); + throw new Error('Registration not found'); + } + await assertEventOpen(registration.eventId, res); + return registration; +} + +// Core computation shared by the cashup preview/close endpoints and the Cashup/Finance/Profit reports. +// +// Two figures must never be conflated: "revenue" (gross, profit-relevant) and "expected cash" +// (net of any costs paid out of a method's float, for physically verifying a drawer/float). +// Mixing them up would double-subtract method-tagged costs from profit. +async function computeEventFinancials(eventId) { + const event = await prisma.event.findUnique({ + where: { id: eventId }, + select: { id: true, title: true, cashupStatus: true, cashupDraft: true, closedAt: true, closedById: true, reopenedAt: true, reopenedById: true } + }); + if (!event) { + throw new Error('Event not found'); + } + + const [payments, costs, tickets, salesRows, history] = await Promise.all([ + prisma.payment.findMany({ + where: { OR: [{ eventId }, { registration: { eventId } }] } + }), + prisma.eventCost.findMany({ where: { eventId }, include: { eventOption: { select: { id: true, name: true } } } }), + prisma.ticket.findMany({ + where: { eventId }, + include: { registrationOption: { select: { eventOptionId: true } } } + }), + prisma.registrationOption.findMany({ + where: { registration: { eventId, status: 'paid' } }, + include: { eventOption: { select: { id: true, name: true, price: true } } } + }), + prisma.eventCashup.findMany({ + where: { eventId }, + include: { + lines: { include: { denominations: true } }, + performedBy: { select: { id: true, name: true, email: true } } + }, + orderBy: { createdAt: 'desc' } + }) + ]); + + // Ticket quantity sold per EventOption (used to price per-item costs) + const quantityByOption = {}; + for (const t of tickets) { + const optId = t.registrationOption?.eventOptionId; + if (!optId) continue; + quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity; + } + + const nonRefundPayments = payments.filter(p => p.amount > 0); + const unallocatedDonations = payments.filter(p => p.isDonation && !p.registrationId); + const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + p.amount, 0); + const totalDonations = payments.filter(p => p.isDonation).reduce((sum, p) => sum + p.amount, 0); + + const paymentsByMethod = emptyByMethod(); + for (const p of nonRefundPayments) { + paymentsByMethod[bucketForMethod(p.method)] += p.amount; + } + const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0); + + // Costs, with computed totals and attribution to a payment method's float (if tagged) + const costBreakdown = costs.map(c => { + const total = c.costType === 'per_item' + ? c.amount * (quantityByOption[c.eventOptionId] || 0) + : c.amount; + return { ...c, total }; + }); + const costsByMethod = emptyByMethod(); + let untaggedCostsTotal = 0; + for (const c of costBreakdown) { + if (c.paidFromMethod && ALL_METHODS.includes(c.paidFromMethod)) { + costsByMethod[c.paidFromMethod] += c.total; + } else { + untaggedCostsTotal += c.total; + } + } + const totalCosts = untaggedCostsTotal + ALL_METHODS.reduce((s, m) => s + costsByMethod[m], 0); + + // What should physically be on hand per method, after known payouts from that float + const expectedCashByMethod = emptyByMethod(); + for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m]; + + // Most recent reconciliation on record (if any) — the source of "actual" truth + const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null; + const reconciled = latestReconciled ? { + id: latestReconciled.id, + action: latestReconciled.action, + createdAt: latestReconciled.createdAt, + performedBy: latestReconciled.performedBy, + notes: latestReconciled.notes, + byMethod: (() => { + const out = {}; + for (const m of ALL_METHODS) { + const line = latestReconciled.lines.find(l => l.method === m); + out[m] = { + expected: line ? line.expectedAmount : expectedCashByMethod[m], + actual: line && line.actualAmount != null ? line.actualAmount : null, + variance: line && line.variance != null ? line.variance : null, + notes: line ? line.notes : null, + denominations: line ? line.denominations : [] + }; + } + return out; + })() + } : null; + + // Reconciled value is truth; system (expected) value is the fallback when nothing was counted. + // Tagged costs are added back so a reconciled cash count still yields a correct *gross* income figure — + // costs get subtracted from profit exactly once, via totalCosts, never twice. + const effectiveGrossIncomeByMethod = emptyByMethod(); + for (const m of ALL_METHODS) { + const actual = reconciled?.byMethod?.[m]?.actual; + const base = actual != null ? actual : expectedCashByMethod[m]; + effectiveGrossIncomeByMethod[m] = base + costsByMethod[m]; + } + const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0); + const netProfit = effectiveTotalRevenue - totalCosts; + + // What was actually sold, by ticket type — for the Finance report's income-stream breakdown + const salesByOptionMap = {}; + for (const ro of salesRows) { + const opt = ro.eventOption; + if (!opt) continue; + if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 }; + const unitPrice = ro.priceSnapshot != null ? ro.priceSnapshot : opt.price; + salesByOptionMap[opt.id].quantitySold += ro.quantity; + salesByOptionMap[opt.id].revenue += unitPrice * ro.quantity; + } + const salesByOption = Object.values(salesByOptionMap); + + return { + event, + paymentsByMethod, + totalRevenue, + costs: costBreakdown, + costsByMethod, + untaggedCostsTotal, + totalCosts, + expectedCashByMethod, + reconciled, + effectiveGrossIncomeByMethod, + effectiveTotalRevenue, + netProfit, + unallocatedDonations, + unallocatedDonationsTotal, + totalDonations, + salesByOption, + history + }; +} + +module.exports = { + METHOD_BUCKETS, + ALL_METHODS, + ZAR_DENOMINATIONS, + bucketForMethod, + assertEventOpen, + assertRegistrationEventOpen, + computeEventFinancials +}; diff --git a/backend/src/utils/cleanupTemp.js b/backend/src/utils/cleanupTemp.js new file mode 100644 index 0000000..b97f166 --- /dev/null +++ b/backend/src/utils/cleanupTemp.js @@ -0,0 +1,39 @@ +const fs = require('fs'); +const path = require('path'); + +const TEMP_DIR = path.join(__dirname, '..', '..', 'temp'); +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +/** + * Deletes files in the temp directory that are older than 7 days. + * Returns a summary: { deleted, errors }. + */ +function cleanupTempFiles() { + try { + if (!fs.existsSync(TEMP_DIR)) return { deleted: 0, errors: 0 }; + + const files = fs.readdirSync(TEMP_DIR); + const cutoff = Date.now() - MAX_AGE_MS; + let deleted = 0; + let errors = 0; + + for (const file of files) { + try { + const filePath = path.join(TEMP_DIR, file); + const stat = fs.statSync(filePath); + if (stat.isFile() && stat.mtimeMs < cutoff) { + fs.unlinkSync(filePath); + deleted++; + } + } catch { + errors++; + } + } + + return { deleted, errors }; + } catch { + return { deleted: 0, errors: 1 }; + } +} + +module.exports = { cleanupTempFiles }; \ No newline at end of file diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js new file mode 100644 index 0000000..a093623 --- /dev/null +++ b/backend/src/utils/email.js @@ -0,0 +1,389 @@ +const nodemailer = require('nodemailer'); + +// ─── Transport ──────────────────────────────────────────────────────────────── +// The SMTP transporter is built lazily and rebuilt whenever the settings cache +// reports a different configuration (e.g. after an admin updates SMTP settings). + +const { getSettingSync } = require('./settingsCache'); + +/** Read current SMTP config from settings cache, falling back to env vars. */ +function _smtpConfig() { + return { + host: getSettingSync('smtp_host', process.env.SMTP_HOST || process.env.EMAIL_HOST || ''), + port: getSettingSync('smtp_port', process.env.SMTP_PORT || process.env.EMAIL_PORT || '587'), + secure: getSettingSync('smtp_secure', process.env.SMTP_SECURE || process.env.EMAIL_SECURE || 'false'), + user: getSettingSync('smtp_user', process.env.SMTP_USER || process.env.EMAIL_USER || ''), + pass: getSettingSync('smtp_pass', process.env.SMTP_PASS || process.env.EMAIL_PASS || ''), + from: getSettingSync('smtp_from', process.env.MAIL_FROM || process.env.EMAIL_FROM || ''), + }; +} + +function _configHash(c) { + return [c.host, c.port, c.secure, c.user, c.pass].join('|'); +} + +let _cachedTransporter = null; +let _cachedConfigHash = null; + +function _getTransporter() { + const cfg = _smtpConfig(); + const hash = _configHash(cfg); + + if (!_cachedTransporter || hash !== _cachedConfigHash) { + _cachedConfigHash = hash; + if (cfg.host) { + _cachedTransporter = nodemailer.createTransport({ + host: cfg.host, + port: parseInt(cfg.port, 10) || 587, + secure: String(cfg.secure).toLowerCase() === 'true' || String(cfg.port) === '465', + auth: cfg.user && cfg.pass ? { user: cfg.user, pass: cfg.pass } : undefined, + }); + } else { + _cachedTransporter = nodemailer.createTransport({ jsonTransport: true }); + if (process.env.NODE_ENV !== 'production') { + console.info('[email] SMTP not configured — using jsonTransport (dev). Configure SMTP via Admin → Site Settings or set SMTP_HOST env var.'); + } + } + } + + return { transporter: _cachedTransporter, cfg }; +} + +async function sendMail({ to, subject, html, text, attachments }) { + // Never send to anonymised/deleted accounts + if (!to || String(to).endsWith('@deleted.invalid')) { + console.info('[email] Skipping send to deleted account:', to); + return; + } + const { transporter, cfg } = _getTransporter(); + const from = cfg.from || 'no-reply@hope-events.local'; + const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) }); + + if (transporter.options && transporter.options.jsonTransport) { + try { + const payload = typeof info.message === 'string' ? JSON.parse(info.message) : info.message; + console.info('[email][dev] simulated:', { to, subject, envelope: info.envelope }); + if (payload && payload.html) console.info('[email][dev] html (first 300 chars):', String(payload.html).slice(0, 300)); + } catch { + console.info('[email][dev] simulated (raw):', info && info.message); + } + } +} + +// ─── Shared template helpers ────────────────────────────────────────────────── + +function getOrg() { + const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001'; + return { + name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'), + tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'), + email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''), + url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''), + headerColor: getSettingSync('accent_color', process.env.EMAIL_HEADER_COLOR || '#1e3a5f'), + }; +} + +/** + * Wraps HTML content in a professional, responsive email shell. + * @param {string} body - Inner HTML content + * @param {{ preheader?: string }} options + */ +function emailWrapper(body, { preheader = '' } = {}) { + const org = getOrg(); + const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`; + return ` + + + + + +${org.name} + + +${preheader ? `
${preheader}‌ ‌ ‌ ‌ 
` : ''} + + +
+ + + + + + + + + + + +
+

${org.name}

+

${org.tagline}

+
+
+ ${body} +
+
+

+ ${org.name} • + ${org.email} • + ${org.url} +

+

+ This email was sent because you have an account or registration with ${org.name}. +

+
+
+ +`; +} + +/** Renders a prominent CTA button. */ +function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) { + return ` + +
+ ${label} +
`; +} + +/** Renders a fallback link below a CTA button. */ +function fallbackLink(url) { + return `

+ Or copy this link: ${url} +

`; +} + +/** Horizontal rule. */ +function divider() { + return `
`; +} + +/** Coloured callout box. type: info | success | warning | danger | neutral */ +function callout(content, type = 'info') { + const map = { + info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' }, + success: { bg: '#f0fdf4', border: '#22c55e', color: '#166534' }, + warning: { bg: '#fffbeb', border: '#f59e0b', color: '#92400e' }, + danger: { bg: '#fef2f2', border: '#ef4444', color: '#991b1b' }, + neutral: { bg: '#f8fafc', border: '#e2e8f0', color: '#475569' }, + }; + const s = map[type] || map.info; + return `
+ ${content} +
`; +} + +/** Numbered payment option row. */ +function paymentOption(num, title, detail) { + return ` + +
${num}
+ + +

${title}

+
${detail}
+ +`; +} + +// ─── Builder functions ──────────────────────────────────────────────────────── + +function buildPasswordResetEmail({ name, resetUrl }) { + const org = getOrg(); + const preheader = `Reset your ${org.name} password. This link expires in 1 hour.`; + const body = ` +

Password reset request

+

We received a request to reset your password.

+ +

Hi ${name || 'there'},

+

Click the button below to choose a new password. This link will expire in 1 hour.

+ + ${ctaButton('Reset my password', resetUrl)} + ${fallbackLink(resetUrl)} + + ${divider()} +

+ If you did not request a password reset, you can safely ignore this email — your password will not be changed. + If you're concerned, contact us at ${org.email}. +

`; + + const text = `Hi ${name || 'there'},\n\nWe received a request to reset your ${org.name} password.\n\nReset link (expires in 1 hour):\n${resetUrl}\n\nIf you did not request this, ignore this email.\n\n${org.name} — ${org.email}`; + return { text, html: emailWrapper(body, { preheader }) }; +} + +function buildPasswordChangedEmail({ name, when, supportEmail }) { + const org = getOrg(); + const contact = supportEmail || org.email; + const whenText = when ? new Date(when).toLocaleString() : 'recently'; + const preheader = `Your ${org.name} password was changed. If this wasn't you, act immediately.`; + const body = ` +

Password changed

+

Security notification for your account

+ +

Hi ${name || 'there'},

+

+ Your ${org.name} account password was successfully changed + ${when ? `on ${whenText}` : 'recently'}. +

+ + ${callout(`This wasn't you?
+ If you did not make this change, your account may be compromised. Contact us immediately at + ${contact} + and reset your password right away.`, 'danger')} + +

+ If you made this change, no further action is needed. This is an automated security notification. +

`; + + const text = `Hi ${name || 'there'},\n\nYour ${org.name} password was changed ${when ? 'on ' + whenText : 'recently'}.\n\nIf you did NOT make this change, contact us immediately at ${contact}.\n\n${org.name} — ${org.email}`; + return { text, html: emailWrapper(body, { preheader }) }; +} + +function buildLoginNotificationEmail({ name, when, location, userAgent }) { + const org = getOrg(); + const preheader = `New login to your ${org.name} account detected.`; + const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`; + const body = ` +

New login detected

+

A new session was opened on your account

+ +

Hi ${name || 'there'},

+ + + + + + + + + + + + + + + + + + +
DetailValue
Time${when}
Location${location}
Device${userAgent}
+ + ${callout(`Not you? If you don't recognise this login, change your password immediately and contact us at ${org.email}.`, 'danger')} + +

If this was you, no action is needed.

`; + + const text = `Hi ${name || 'there'},\n\nA new login to your ${org.name} account was detected.\n\nTime: ${when}\nLocation: ${location}\nDevice: ${userAgent}\n\nIf this was NOT you, change your password immediately and contact ${org.email}.\n\n${org.name}`; + return { text, html: emailWrapper(body, { preheader }) }; +} + +function buildWelcomeEmail({ name, events }) { + const org = getOrg(); + const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`; + const hasEvents = Array.isArray(events) && events.length > 0; + const preheader = `Welcome to ${org.name}! Your account is ready.`; + + const eventsBlock = hasEvents + ? `${divider()} +

Upcoming events

+ + ${events.map(e => ` + + `).join('')} +
+

${e.title}

+

${new Date(e.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}

+
+ ${ctaButton('Browse all events', org.url + '/events')}` + : `

Keep an eye on our website — new events are added regularly!

+ ${ctaButton('View events', org.url + '/events')}`; + + const body = ` +

Welcome to ${org.name}!

+

Your account has been created

+ +

Hi ${name || 'there'},

+

+ Your ${org.name} account is set up and ready to go. Use your account to register for events, manage your bookings, and view your tickets — all in one place. +

+ + ${eventsBlock} + + ${divider()} +

We look forward to seeing you at our events!

`; + + const eventsText = hasEvents + ? `Upcoming events:\n${events.map(e => `- ${e.title} (${new Date(e.startDate).toLocaleDateString()})`).join('\n')}` + : 'Keep an eye on our website for upcoming events.'; + const text = `Welcome to ${org.name}, ${name || 'there'}!\n\nYour account has been created successfully.\n\n${eventsText}\n\n${org.url}\n\n${org.name} — ${org.email}`; + return { text, html: emailWrapper(body, { preheader }) }; +} + +function buildAccountActivationEmail({ name, activationUrl }) { + const org = getOrg(); + const preheader = `Activate your ${org.name} account to get started.`; + const body = ` +

Activate your account

+

One step to access your events and tickets

+ +

Hi ${name || 'there'},

+

+ Your ${org.name} account is ready, but needs to be activated. Click below to set your password and access your registrations, tickets, and more. +

+ + ${ctaButton('Activate my account', activationUrl, { bg: '#059669' })} + ${fallbackLink(activationUrl)} + + ${callout('This activation link expires in 24 hours.', 'warning')} + +

+ If you didn't expect this email, you can safely ignore it. +

`; + + const text = `Hi ${name || 'there'},\n\nActivate your ${org.name} account by visiting the link below:\n\n${activationUrl}\n\nThis link expires in 24 hours.\n\nIf you didn't expect this, ignore this email.\n\n${org.name} — ${org.email}`; + return { text, html: emailWrapper(body, { preheader }) }; +} + +function buildAccountClosedEmail({ name, dataDeleted }) { + const org = getOrg(); + const preheader = `Your ${org.name} account has been closed.`; + const body = ` +

Account closed

+

Confirmation of account closure

+ +

Hi ${name || 'there'},

+

+ This email confirms that your ${org.name} account has been successfully closed. +

+ ${dataDeleted + ? `

All personal data associated with your account has been permanently deleted as requested.

` + : `

Your account has been deactivated. If you would also like your personal data permanently deleted, please contact us at ${org.email}.

` + } +

+ If you did not request this, please contact us immediately at + ${org.email}. +

`; + + const dataNote = dataDeleted + ? 'All personal data has been permanently deleted.' + : 'Your account has been deactivated. Contact us if you also want your data deleted.'; + const text = `Hi ${name || 'there'},\n\nYour ${org.name} account has been closed.\n\n${dataNote}\n\nIf you did not request this, contact us immediately at ${org.email}.\n\n${org.name}`; + return { text, html: emailWrapper(body, { preheader }) }; +} + +module.exports = { + sendMail, + emailWrapper, + ctaButton, + fallbackLink, + divider, + callout, + paymentOption, + buildPasswordResetEmail, + buildPasswordChangedEmail, + buildLoginNotificationEmail, + buildWelcomeEmail, + buildAccountActivationEmail, + buildAccountClosedEmail, +}; \ No newline at end of file diff --git a/backend/src/utils/encryption.js b/backend/src/utils/encryption.js new file mode 100644 index 0000000..1bb397a --- /dev/null +++ b/backend/src/utils/encryption.js @@ -0,0 +1,79 @@ +/** + * AES-256-GCM encryption helpers for sensitive settings stored in the database. + * + * Encrypted values are stored with an "enc:" prefix so they can be detected + * and decrypted transparently when read back from the cache. + * + * Key is derived once at startup from JWT_SECRET via scrypt so it never + * appears in plain text in the codebase. + */ + +const crypto = require('crypto'); + +const SENTINEL = 'enc:'; + +// Derive a stable 32-byte key from JWT_SECRET using scrypt. +// The fixed salt is fine here — its purpose is to namespace this key +// so it can't be confused with keys derived for other purposes. +const ENCRYPTION_KEY = (() => { + const secret = process.env.JWT_SECRET || 'default-unsafe-key-CHANGE-IN-PRODUCTION'; + if (secret === 'default-unsafe-key-CHANGE-IN-PRODUCTION') { + console.warn('[encryption] WARNING: JWT_SECRET is not set. Sensitive settings will be encrypted with an insecure fallback key. Set JWT_SECRET in production.'); + } + return crypto.scryptSync(secret, 'hope-events-settings-aes256gcm-v1', 32); +})(); + +/** + * Encrypt a plaintext string. Returns an "enc:::" string. + * Returns the original value if it is already encrypted or falsy. + * @param {string} plaintext + * @returns {string} + */ +function encrypt(plaintext) { + if (!plaintext) return plaintext; + if (typeof plaintext !== 'string') plaintext = String(plaintext); + if (plaintext.startsWith(SENTINEL)) return plaintext; // already encrypted + const iv = crypto.randomBytes(12); // 96-bit IV for GCM + const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`; +} + +/** + * Decrypt a value produced by encrypt(). Returns the plaintext. + * Returns the original value unchanged if it does not start with "enc:". + * @param {string} value + * @returns {string} + */ +function decrypt(value) { + if (!value || typeof value !== 'string') return value; + if (!value.startsWith(SENTINEL)) return value; // not encrypted + try { + const inner = value.slice(SENTINEL.length); + const parts = inner.split(':'); + if (parts.length !== 3) throw new Error('Invalid encrypted value format'); + const [ivHex, tagHex, cipherHex] = parts; + const iv = Buffer.from(ivHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); + const ciphertext = Buffer.from(cipherHex,'hex'); + const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return decrypted.toString('utf8'); + } catch (err) { + console.error('[encryption] Failed to decrypt value:', err.message); + return ''; // return empty rather than crashing + } +} + +/** + * Returns true if the value looks like it was produced by encrypt(). + * @param {string} value + * @returns {boolean} + */ +function isEncrypted(value) { + return typeof value === 'string' && value.startsWith(SENTINEL); +} + +module.exports = { encrypt, decrypt, isEncrypted }; \ No newline at end of file diff --git a/backend/src/utils/errorUtils.js b/backend/src/utils/errorUtils.js new file mode 100644 index 0000000..4047090 --- /dev/null +++ b/backend/src/utils/errorUtils.js @@ -0,0 +1,43 @@ +// Prisma error codes all match P followed by 4 digits +const PRISMA_CODE_RE = /^P\d{4}$/; + +/** + * Returns a client-safe error message. + * Prisma errors and connection errors can expose table names, column names, + * DB hostnames and ports — replace those with generic messages. + * Intentional application errors (thrown with `new Error('...')` in controllers) + * are passed through unchanged. + */ +function safeErrorMessage(err) { + if (!err) return 'An unexpected error occurred.'; + + const code = String(err.code || ''); + + // Prisma client / query engine errors + if (PRISMA_CODE_RE.test(code)) { + switch (code) { + case 'P2002': return 'A record with that value already exists.'; + case 'P2025': return 'Record not found.'; + case 'P2003': return 'Operation failed due to a related record constraint.'; + case 'P2016': return 'Required record not found.'; + default: return 'A database error occurred. Please try again.'; + } + } + + const msg = String(err.message || ''); + + // DB connection / network errors leak hostnames and ports + if ( + msg.includes("Can't reach database") || + msg.includes('ECONNREFUSED') || + msg.includes('ETIMEDOUT') || + msg.includes('Connection refused') || + msg.includes('database server') + ) { + return 'A database connection error occurred. Please try again later.'; + } + + return msg || 'An unexpected error occurred.'; +} + +module.exports = { safeErrorMessage }; \ No newline at end of file diff --git a/backend/src/utils/notifications.js b/backend/src/utils/notifications.js new file mode 100644 index 0000000..5239dae --- /dev/null +++ b/backend/src/utils/notifications.js @@ -0,0 +1,984 @@ +const prisma = require('../config/db'); +const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email'); +const { computeRegistrationTotalDue } = require('./pricing'); + +// ─── Formatting helpers ─────────────────────────────────────────────────────── + +const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`; + +function fmtAmount(amt) { + const n = Number(amt || 0); + return `R${n.toFixed(2)}`; +} + +function fmtDate(d) { + try { return new Date(d).toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return String(d); } +} + +function fmtDateShort(d) { + try { return new Date(d).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); } catch { return String(d); } +} + +const { getSettingSync } = require('./settingsCache'); + +function getOrg() { + return { + name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'), + email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '', + url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''), + }; +} + +function getRegistrationsInbox() { + // Supports comma-separated list; return the first address for single-recipient use + const raw = getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || ''); + return raw.split(',')[0].trim() || ''; +} + +function getRegistrationsInboxAll() { + // Returns the full comma-separated string for multi-recipient sends + return getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || ''); +} + +/** + * Resolves who should receive registration/payment/daily-summary notices for an event: + * the event's configured `notifyRecipients`, or the event creator when none are set. + */ +function getEventNotifyEmails(event) { + const recipients = Array.isArray(event?.notifyRecipients) ? event.notifyRecipients : []; + const emails = recipients.map(u => u?.email).filter(Boolean); + if (emails.length) return emails; + return event?.createdBy?.email ? [event.createdBy.email] : []; +} + +/** + * Builds the deduplicated admin "to" list for an event notification: the global + * registrations inbox plus that event's notify recipients (or its creator as fallback). + */ +function buildEventNotifyRecipientList(event) { + const inbox = getRegistrationsInbox(); + const seen = new Set(); + const to = []; + if (inbox) { seen.add(inbox); to.push(inbox); } + for (const email of getEventNotifyEmails(event)) { + if (!seen.has(email)) { seen.add(email); to.push(email); } + } + return to; +} + +// ─── Data loaders ───────────────────────────────────────────────────────────── + +async function loadRegistrationFull(registrationId) { + return prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true, + user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } }, + event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, + }, + }); +} + +async function loadPaymentFull(paymentId) { + return prisma.payment.findUnique({ + where: { id: paymentId }, + include: { + user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } }, + registration: { + include: { + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true, + user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } }, + event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, + }, + }, + event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, + }, + }); +} + +// ─── Shared building blocks ─────────────────────────────────────────────────── + +/** Renders the selections summary table. */ +function selectionsTable(registrationOptions) { + const rows = (registrationOptions || []).map(ro => { + const name = ro.eventOption?.name || 'Option'; + const qty = ro.quantity || 1; + const price = ro.eventOption?.price || 0; + return ` + ${name} + ×${qty} + ${fmtAmount(price * qty)} + `; + }); + + if (!rows.length) return `

No items

`; + + return ` + + + + + + ${rows.join('')} +
ItemQtyAmount
`; +} + +/** Renders a financial summary line. */ +function financialSummary(totalDue, totalPaid, balance) { + const ff2 = ff; + return ` + + + + + + + + + + + + +
Total due${fmtAmount(totalDue)}
Amount paid${fmtAmount(totalPaid)}
${balance <= 0 ? 'Fully paid' : 'Balance due'}${fmtAmount(balance)}
`; +} + +/** + * Renders the account CTA section at the bottom of user-facing registration emails. + * isActive: true → Login prompt; false → Create account prompt. + */ +function accountCta(isActive, siteUrl) { + if (isActive) { + return `${divider()} +

Manage your registration online

+

+ Log in to your account to view your registration, make payments, and download your tickets. +

+ ${ctaButton('Log in to your account', siteUrl + '/login', { bg: '#0f172a' })}`; + } + return `${divider()} +

Create your account

+

+ Create a free account to manage your registrations, make payments online, and access your tickets — all in one place. +

+ ${ctaButton('Create your account', siteUrl + '/register', { bg: '#0f172a' })}`; +} + +/** + * Renders the payment options section for registration emails. + * @param {{ balance, yocoLink, source, siteUrl, formRequired }} + * source: 'admin' (manual/self-service/at-door) | 'user' (self-register via website) + */ +function paymentSection({ balance, yocoLink, source, siteUrl, formRequired, isUserActive }) { + if (balance <= 0 && !formRequired) { + return callout( + `🎟️ You\'re all set!
+ No payment required. Your tickets have been sent in a separate email.`, + 'success' + ); + } + + if (balance <= 0 && formRequired) { + return callout( + `One more step — attendee form required
+ This event requires an attendee information form before tickets can be issued. The form was presented during registration. If you haven't submitted it yet, please contact us.`, + 'warning' + ); + } + + // Balance due — build payment options + const isAdmin = source === 'admin'; + let optNum = 1; + const options = []; + + if (isAdmin && yocoLink) { + options.push(paymentOption( + optNum++, + 'Pay online now (quickest)', + `${yocoLink}
+ Already paid? You can safely ignore this option.` + )); + } + + options.push(paymentOption( + optNum++, + `Pay via our website`, + `Visit ${siteUrl} to pay online.${ + isUserActive === false + ? `
You'll need to create a free account to pay online.` + : isUserActive === true + ? `
Log in to access your registration and pay.` + : '' + }` + )); + + options.push(paymentOption( + optNum++, + 'Pay at the door', + 'Cash and card accepted at the event entrance. No need to pre-pay — your registration is already confirmed.' + )); + + return `

How to pay

+

Choose any of the following payment methods:

+ ${options.join('')}
+

Your tickets will be emailed once your payment is confirmed.

`; +} + +// ─── Registration confirmation (user self-registered via website) ────────────── + +function buildRegistrationConfirmation(reg, { isNew = true } = {}) { + const org = getOrg(); + const eventTitle = reg.event?.title || 'the event'; + const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : ''; + const totalDue = computeRegistrationTotalDue(reg, new Date()); + const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const balance = Math.max(totalDue - totalPaid, 0); + const isUserActive = reg.user?.isActive; + + const heading = isNew ? 'Registration confirmed!' : 'Registration updated'; + const subtext = isNew + ? `Thank you for registering for ${eventTitle}.` + : `Your registration for ${eventTitle} has been updated.`; + const subject = isNew ? `Registration confirmed – ${eventTitle}` : `Registration updated – ${eventTitle}`; + const preheader = isNew + ? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!` + : `Your registration for ${eventTitle} has been updated.`; + + const body = ` +

${heading}

+

${isNew ? 'Your spot is reserved' : 'Changes saved'}

+ +

Hi ${reg.user?.name || 'there'},

+

${subtext}${eventDate ? ` — ${eventDate}` : ''}

+ +

Your registration

+ ${selectionsTable(reg.registrationOptions)} + ${financialSummary(totalDue, totalPaid, balance)} + + ${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })} + ${accountCta(isUserActive, org.url)}`; + + const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); + const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name} — ${org.email}\n${org.url}`; + + return { subject, text, html: emailWrapper(body, { preheader }) }; +} + +// ─── Registration confirmation (admin / self-service / at-door) ─────────────── + +function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, formRequired = false, isNew = true } = {}) { + const org = getOrg(); + const eventTitle = reg.event?.title || 'the event'; + const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : ''; + const totalDue = computeRegistrationTotalDue(reg, new Date()); + const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const balance = Math.max(totalDue - totalPaid, 0); + const isUserActive = reg.user?.isActive; + + const heading = isNew ? 'Registration confirmed!' : 'Registration updated'; + const subtext = isNew + ? `You have been registered for ${eventTitle}.` + : `Your registration for ${eventTitle} has been updated.`; + const subject = isNew ? `Registration confirmed – ${eventTitle}` : `Registration updated – ${eventTitle}`; + const preheader = isNew + ? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!` + : `Your registration for ${eventTitle} has been updated.`; + + const body = ` +

${heading}

+

${isNew ? 'Your spot is reserved' : 'Changes saved'}

+ +

Hi ${reg.user?.name || 'there'},

+

${subtext}${eventDate ? ` The event takes place on ${eventDate}.` : ''}

+ +

Your registration

+ ${selectionsTable(reg.registrationOptions)} + ${financialSummary(totalDue, totalPaid, balance)} + + ${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })} + ${accountCta(isUserActive, org.url)}`; + + const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); + const payText = balance > 0 + ? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` + : formRequired + ? 'An attendee form is required before your tickets are issued. Please complete it at the registration desk.' + : 'No payment required — your tickets have been sent in a separate email.'; + const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You have been registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${payText}\n\n${org.name} — ${org.email}\n${org.url}`; + + return { subject, text, html: emailWrapper(body, { preheader }) }; +} + +// ─── Admin notification (internal) ─────────────────────────────────────────── + +function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } = {}) { + const org = getOrg(); + const eventTitle = reg.event?.title || 'Event'; + const totalDue = computeRegistrationTotalDue(reg, new Date()); + const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const balance = Math.max(totalDue - totalPaid, 0); + const verb = isUpdated ? 'updated' : (isNew ? 'created' : 'modified'); + const subject = isUpdated + ? `Registration updated: ${eventTitle} — ${reg.user?.name || 'Unknown'}` + : `New registration: ${eventTitle} — ${reg.user?.name || 'Unknown'}`; + + const itemRows = (reg.registrationOptions || []).map(ro => + ` + ${ro.eventOption?.name || 'Option'} + ×${ro.quantity} + ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)} + `).join(''); + + const body = ` +

Registration ${verb}

+

${org.name} — internal notification

+ + + + + + + + + + + + + + ${reg.user?.phoneNumber ? ` + + + ` : ''} + + + + + + + + + + + + +
Registrant details
Name${reg.user?.name || '—'}
Email${reg.user?.email || '—'}
Phone${reg.user.phoneNumber}
Event${eventTitle}
Status${reg.status || 'pending'}
Reg ID${reg.id}
+ + ${itemRows ? `

Items

+ + + + + + ${itemRows} +
OptionQtyAmount
` : ''} + + + + + + + + + + + + + + +
Total due${fmtAmount(totalDue)}
Paid${fmtAmount(totalPaid)}
Balance${fmtAmount(balance)}
`; + + const to = buildEventNotifyRecipientList(reg.event); + const text = `Registration ${verb}\n\nEvent: ${eventTitle}\nName: ${reg.user?.name || '—'}\nEmail: ${reg.user?.email || '—'}\nStatus: ${reg.status || 'pending'}\nTotal due: ${fmtAmount(totalDue)} | Paid: ${fmtAmount(totalPaid)} | Balance: ${fmtAmount(balance)}\nReg ID: ${reg.id}`; + return { to, subject, text, html: emailWrapper(body) }; +} + +// ─── Payment receipt ────────────────────────────────────────────────────────── + +function buildPaymentReceipt(payment) { + const org = getOrg(); + const isReg = !!payment.registrationId && payment.registration; + const eventTitle = isReg + ? (payment.registration?.event?.title || 'the event') + : (payment.event?.title || 'the event'); + + if (isReg) { + const reg = payment.registration; + const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date()); + const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const balance = Math.max(totalDue - totalPaid, 0); + const isUserActive = reg.user?.isActive; + const subject = `Payment received – ${eventTitle}`; + const preheader = `We received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.`; + + const historyRows = (reg.payments || []) + .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + .map(p => ` + ${fmtDate(p.createdAt)} + ${p.method || '—'} + ${fmtAmount(p.amount)} + `).join(''); + + const body = ` +

Payment received

+

Thank you — we've got your payment

+ +

Hi ${payment.user?.name || 'there'},

+

+ We received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}. +

+ + ${callout(`${fmtAmount(payment.amount)} received
+ Payment method: ${payment.method || '—'} • Date: ${fmtDate(payment.createdAt)}`, + 'success')} + + + + + + + + + + + + + + +
Total due${fmtAmount(totalDue)}
Total paid${fmtAmount(totalPaid)}
${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}${fmtAmount(balance)}
+ + ${historyRows ? `

Payment history

+ + + + + + ${historyRows} +
DateMethodAmount
` : ''} + + ${balance <= 0 + ? callout('You\'re fully paid! Your tickets have been emailed to you separately.', 'success') + : ''} + + ${accountCta(isUserActive, org.url)}`; + + const text = `Payment received\n\nHi ${payment.user?.name || 'there'},\n\nWe received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\nThank you!\n\n${org.name} — ${org.email}`; + return { subject, text, html: emailWrapper(body, { preheader }) }; + } + + // Donation receipt + const subject = `Donation received – ${eventTitle}`; + const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`; + const body = ` +

Thank you for your donation!

+

Your generosity makes a difference

+ +

Hi ${payment.user?.name || 'there'},

+

+ We received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}. Your support means the world to us. +

+ + ${callout(`${fmtAmount(payment.amount)} donated
+ Method: ${payment.method || '—'} • Date: ${fmtDate(payment.createdAt)}`, + 'success')} + +

We appreciate your generous support. Thank you!

`; + + const text = `Thank you for your donation!\n\nHi ${payment.user?.name || 'there'},\n\nWe received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.\n\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\n\nThank you!\n\n${org.name} — ${org.email}`; + return { subject, text, html: emailWrapper(body, { preheader }) }; +} + +// ─── Donation applied to a registration ──────────────────────────────────────── +// +// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply +// someone else's donation to their registration — they didn't pay anything themselves, +// so "payment received" wording would be wrong. Kept anonymous (no donor name) by design. + +function buildDonationAppliedToRegistrant(payment) { + const org = getOrg(); + const reg = payment.registration; + const eventTitle = reg?.event?.title || 'the event'; + const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date()); + const totalPaid = (reg?.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const balance = Math.max(totalDue - totalPaid, 0); + const isUserActive = reg?.user?.isActive; + const subject = `A donation was applied to your registration – ${eventTitle}`; + const preheader = `A donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle}.`; + + const body = ` +

A donation was applied to your registration

+

Good news about your balance

+ +

Hi ${reg?.user?.name || 'there'},

+

+ A donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle} by our team. +

+ + ${callout(`${fmtAmount(payment.amount)} applied
+ Date: ${fmtDate(payment.createdAt)}`, + 'success')} + + + + + + + + + + + + + + +
Total due${fmtAmount(totalDue)}
Total paid${fmtAmount(totalPaid)}
${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}${fmtAmount(balance)}
+ + ${balance <= 0 + ? callout('You\'re fully paid! Your tickets have been emailed to you separately.', 'success') + : ''} + + ${accountCta(isUserActive, org.url)}`; + + const text = `A donation was applied to your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle} by our team.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${org.name} — ${org.email}`; + return { subject, text, html: emailWrapper(body, { preheader }) }; +} + +// ─── Payment admin notification ─────────────────────────────────────────────── + +function buildPaymentAdminNotice(payment) { + const isReg = !!payment.registrationId && payment.registration; + const eventTitle = isReg ? (payment.registration?.event?.title || 'Event') : (payment.event?.title || 'Event'); + const payerName = payment.user?.name || '—'; + const payerEmail = payment.user?.email || '—'; + const subject = `Payment recorded: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`; + const type = isReg ? 'Registration payment' : 'Donation'; + + const body = ` +

Payment recorded

+

Internal notification

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${payment.externalId ? ` + + + ` : ''} +
Payment details
Amount${fmtAmount(payment.amount)}
Type${type}
Payer${payerName} <${payerEmail}>
Event${eventTitle}
Method${payment.method || '—'}
Date${fmtDate(payment.createdAt)}
Payment ID${payment.id}
External ID${payment.externalId}
`; + + const event = isReg ? payment.registration?.event : payment.event; + const to = buildEventNotifyRecipientList(event); + const text = `Payment recorded\n\nAmount: ${fmtAmount(payment.amount)}\nType: ${type}\nPayer: ${payerName} <${payerEmail}>\nEvent: ${eventTitle}\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\nID: ${payment.id}`; + return { to, subject, text, html: emailWrapper(body) }; +} + +// ─── Refund email ───────────────────────────────────────────────────────────── + +function buildRefundEmail(payment) { + const org = getOrg(); + const user = payment.user; + const amt = Math.abs(payment.amount || 0); + const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event'; + const subject = `Refund processed – ${fmtAmount(amt)} for ${eventTitle}`; + const preheader = `Your refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.`; + + const body = ` +

Refund processed

+

Your refund is on its way

+ +

Hi ${user?.name || 'there'},

+

+ A refund of ${fmtAmount(amt)} has been processed for ${eventTitle}. + ${payment.status ? `
Reason: ${payment.status}` : ''} +

+ + ${callout(`${fmtAmount(amt)} refunded
+ Method: ${payment.method || 'original payment method'} • Date: ${fmtDate(payment.createdAt)}`, + 'info')} + +

+ Refunds may take a few business days to appear depending on your bank and payment method. + If you have any questions, contact us at ${org.email}. +

`; + + const text = `Refund processed\n\nHi ${user?.name || 'there'},\n\nA refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.\n\nMethod: ${payment.method || 'original method'}\nDate: ${fmtDate(payment.createdAt)}\n\n${org.name} — ${org.email}`; + return { subject, text, html: emailWrapper(body, { preheader }) }; +} + +// ─── Daily event summary ────────────────────────────────────────────────────── + +function buildDailySummary(ev, registrations, payments, now) { + const org = getOrg(); + const subject = `Daily summary: ${ev.title} — ${new Date(now).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`; + + const regRows = registrations.map(r => { + const totalDue = computeRegistrationTotalDue(r, now); + const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const balance = Math.max(totalDue - totalPaid, 0); + return { name: r.user?.name || '?', email: r.user?.email || '', status: r.status, totalDue, totalPaid, balance }; + }); + + const payRows = payments.map(p => ({ + date: fmtDate(p.createdAt), + amount: p.amount, + method: p.method || '—', + isDonation: p.isDonation || !p.registrationId, + payerName: p.registration?.user?.name || p.user?.name || '?', + payerEmail: p.user?.email || p.registration?.user?.email || '', + })); + + const totalRegistrations = regRows.length; + const totalRevenue = payRows.reduce((s, p) => s + (p.amount || 0), 0); + const paidCount = regRows.filter(r => r.status === 'paid').length; + const pendingCount = regRows.filter(r => r.status === 'pending' || r.status === 'partial_paid').length; + + const statsRow = (label, value, color = '#1e293b') => + ` +
${value}
+
${label}
+ `; + + const regTableHtml = regRows.length + ? ` + + + + + + + + + ${regRows.map((r, i) => ` + + + + + + + `).join('')} +
NameEmailStatusTotalPaidBalance
${r.name}${r.email} + ${r.status} + ${fmtAmount(r.totalDue)}${fmtAmount(r.totalPaid)}${fmtAmount(r.balance)}
` + : `

No registrations yet.

`; + + const payTableHtml = payRows.length + ? ` + + + + + + + + ${payRows.map((p, i) => ` + + + + + + `).join('')} +
DatePayerTypeMethodAmount
${p.date}${p.payerName}${p.payerEmail ? `
${p.payerEmail}` : ''}
+ ${p.isDonation ? 'Donation' : 'Registration'} + ${p.method}${fmtAmount(p.amount)}
` + : `

No payments recorded yet.

`; + + const body = ` +

Daily Summary

+

${new Date(now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}

+

${ev.title}

+ + + + ${statsRow('Total registrations', totalRegistrations)} + ${statsRow('Confirmed paid', paidCount, '#059669')} + ${statsRow('Awaiting payment', pendingCount, '#d97706')} + + +
+
${fmtAmount(totalRevenue)}
+
Total collected
+
+ + ${divider()} +

Registrations

+ ${regTableHtml} + +

Payments & Donations

+ ${payTableHtml} + +

+ Event starts: ${fmtDate(ev.startDate)} • Event ends: ${fmtDate(ev.endDate)} +

`; + + const text = [ + `Daily Summary — ${ev.title}`, + new Date(now).toLocaleDateString(), + '', + `Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount} | Revenue: ${fmtAmount(totalRevenue)}`, + '', + 'Registrations:', + ...(regRows.length ? regRows.map(r => ` ${r.name} <${r.email}> — ${r.status} — due: ${fmtAmount(r.totalDue)} paid: ${fmtAmount(r.totalPaid)} balance: ${fmtAmount(r.balance)}`) : [' (none)']), + '', + 'Payments:', + ...(payRows.length ? payRows.map(p => ` ${p.date} — ${fmtAmount(p.amount)} via ${p.method} — ${p.isDonation ? 'Donation' : 'Registration'} — ${p.payerName}`) : [' (none)']), + ].join('\n'); + + return { subject, text, html: emailWrapper(body) }; +} + +// ─── Send functions ─────────────────────────────────────────────────────────── + +async function sendRegistrationEmails(registrationId) { + try { + const reg = await loadRegistrationFull(registrationId); + if (!reg) return; + const { shouldEmail, waText } = require('./notify'); + const { buildWARegistration } = require('./waMessages'); + const { computeRegistrationTotalDue } = require('./pricing'); + + const sends = []; + const totalDue = computeRegistrationTotalDue(reg, new Date()); + const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + + // Email: only for real addresses (skip guest.local placeholders) + if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { + const msg = buildRegistrationConfirmation(reg, { isNew: true }); + if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); + } + // WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally + sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); + + const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true }); + if (adminMsg.to && adminMsg.to.length) { + sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); + } + await Promise.all(sends); + } catch (e) { + console.error('Failed to send registration emails:', e); + } +} + +async function sendRegistrationUpdatedEmails(registrationId) { + try { + const reg = await loadRegistrationFull(registrationId); + if (!reg) return; + const { shouldEmail, waText } = require('./notify'); + const { buildWARegistration } = require('./waMessages'); + const { computeRegistrationTotalDue } = require('./pricing'); + + const sends = []; + const totalDue = computeRegistrationTotalDue(reg, new Date()); + const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { + const msg = buildRegistrationConfirmation(reg, { isNew: false }); + if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); + } + sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); + const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true }); + if (adminMsg.to && adminMsg.to.length) { + sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); + } + await Promise.all(sends); + } catch (e) { + console.error('Failed to send registration updated emails:', e); + } +} + +async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) { + try { + const reg = await loadRegistrationFull(registrationId); + if (!reg) return; + const { shouldEmail, waText } = require('./notify'); + const { buildWARegistration } = require('./waMessages'); + const { computeRegistrationTotalDue } = require('./pricing'); + + const sends = []; + const totalDue = computeRegistrationTotalDue(reg, new Date()); + const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { + const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew }); + if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); + } + sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); + const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew }); + if (adminMsg.to && adminMsg.to.length) { + sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); + } + await Promise.all(sends); + } catch (e) { + console.error('Failed to send self-service registration emails:', e); + } +} + +async function sendPaymentEmails(paymentId) { + try { + const payment = await loadPaymentFull(paymentId); + if (!payment) return; + const user = payment.registration?.user || payment.user; + const { shouldEmail, waText, waTextAny } = require('./notify'); + const { buildWAPayment } = require('./waMessages'); + + const sends = []; + const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'); + if (hasValidEmail) { + const msg = buildPaymentReceipt(payment); + if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })); + } + // WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't + if (hasValidEmail) { + sends.push(waText(user, buildWAPayment(payment))); + } else { + sends.push(waTextAny(user, buildWAPayment(payment))); + } + const hasEvent = !!(payment.registration?.eventId || payment.eventId); + if (hasEvent) { + const adminMsg = buildPaymentAdminNotice(payment); + if (adminMsg.to && adminMsg.to.length) { + sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); + } + } + await Promise.all(sends); + } catch (e) { + console.error('Failed to send payment emails:', e); + } +} + +async function sendRefundEmail(refundPaymentId) { + try { + const payment = await loadPaymentFull(refundPaymentId); + if (!payment) return; + + const user = payment.user; + if (!user?.email || user.email.endsWith('@guest.local')) return; + + const msg = buildRefundEmail(payment); + const sends = [sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })]; + + const adminMsg = buildPaymentAdminNotice(payment); + if (adminMsg.to && adminMsg.to.length) { + sends.push(sendMail({ to: adminMsg.to.join(','), subject: `Refund: ${adminMsg.subject}`, html: adminMsg.html, text: adminMsg.text })); + } + await Promise.all(sends); + } catch (e) { + console.error('Failed to send refund email:', e); + } +} + +// Sent when staff apply a donation to someone's registration. Only the registrant is +// notified (anonymously, per design) — the donor already received their donation-received +// notification when the donation was originally made, so they are deliberately not emailed +// again here, and any leftover/unassigned remainder from a partial allocation is silent too. +async function sendDonationAssignmentEmails(paymentId) { + try { + const payment = await loadPaymentFull(paymentId); + if (!payment || !payment.registration) return; + const user = payment.registration.user; + const { shouldEmail, waText, waTextAny } = require('./notify'); + const { buildWADonationAppliedToRegistrant } = require('./waMessages'); + + const sends = []; + const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'); + if (hasValidEmail) { + const msg = buildDonationAppliedToRegistrant(payment); + if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })); + } + if (hasValidEmail) { + sends.push(waText(user, buildWADonationAppliedToRegistrant(payment))); + } else { + sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment))); + } + const adminMsg = buildPaymentAdminNotice(payment); + if (adminMsg.to && adminMsg.to.length) { + sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); + } + await Promise.all(sends); + } catch (e) { + console.error('Failed to send donation-assignment emails:', e); + } +} + +async function sendDailyEventSummaries(now = new Date()) { + try { + const today = new Date(now); + const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } }; + let events = await prisma.event.findMany({ + where: { isActive: true, startDate: { gte: today } }, + include: notifyInclude, + orderBy: { startDate: 'asc' }, + }); + + try { + events = await prisma.event.findMany({ + where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } }, + include: notifyInclude, + orderBy: { startDate: 'asc' }, + }); + } catch {} + + await Promise.allSettled(events.map(async ev => { + const registrations = await prisma.registration.findMany({ + where: { eventId: ev.id }, + include: { + user: { select: { id: true, name: true, email: true, phoneNumber: true } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + payments: true, + }, + orderBy: { createdAt: 'asc' }, + }); + + const payments = await prisma.payment.findMany({ + where: { OR: [{ eventId: ev.id }, { registration: { eventId: ev.id } }] }, + include: { + user: { select: { id: true, name: true, email: true } }, + registration: { select: { id: true, user: { select: { id: true, name: true, email: true } } } }, + }, + orderBy: { createdAt: 'asc' }, + }); + + const { subject, text, html } = buildDailySummary(ev, registrations, payments, now); + const to = buildEventNotifyRecipientList(ev); + if (to.length) await sendMail({ to: to.join(','), subject, html, text }); + })); + } catch (e) { + console.error('Failed to send daily event summaries:', e); + } +} + +module.exports = { + sendRegistrationEmails, + sendRegistrationUpdatedEmails, + sendPaymentEmails, + sendDailyEventSummaries, + sendRefundEmail, + sendSelfServiceRegistrationEmails, + sendDonationAssignmentEmails, +}; diff --git a/backend/src/utils/notify.js b/backend/src/utils/notify.js new file mode 100644 index 0000000..0c7b5d3 --- /dev/null +++ b/backend/src/utils/notify.js @@ -0,0 +1,82 @@ +/** + * Routing helpers for sending notifications based on a user's + * notificationPreference (email | whatsapp | both). + * + * Security-critical messages (password reset, login alert, password changed, + * account closed) always send email regardless of preference, and additionally + * send a WhatsApp message when preference includes it. + * + * Informational messages (registration, payment, tickets) respect the + * preference fully. + */ + +const { isValidZAPhone, sendText, sendPdf } = require('./whatsapp'); + +/** Returns true when the user should receive a WhatsApp notification. */ +function canWhatsApp(user) { + if (!user) return false; + const pref = user.notificationPreference || 'email'; + return (pref === 'whatsapp' || pref === 'both') && isValidZAPhone(user.phoneNumber); +} + +/** Returns true when the user should receive an email notification. */ +function shouldEmail(user) { + if (!user) return false; + const pref = user.notificationPreference || 'email'; + return pref === 'email' || pref === 'both'; +} + +/** + * Fire-and-forget WhatsApp text to a user. + * Silently skips if the user can't receive WhatsApp. + */ +async function waText(user, message) { + if (!canWhatsApp(user)) return; + try { + await sendText(user.phoneNumber, message); + } catch (e) { + console.warn('[notify] WhatsApp text failed:', e?.response?.data?.message || e.message); + } +} + +/** + * Fire-and-forget WhatsApp PDF to a user. + * Silently skips if the user can't receive WhatsApp. + */ +async function waPdf(user, localPdfPath, filename, caption) { + if (!canWhatsApp(user)) return; + try { + await sendPdf(user.phoneNumber, localPdfPath, filename, caption); + } catch (e) { + console.warn('[notify] WhatsApp PDF failed:', e?.response?.data?.message || e.message); + } +} + +/** + * Like waText but bypasses the notification preference check. + * Use as a fallback when email is not available (guest / no valid email), + * so the user still receives notifications via WhatsApp if they have a phone. + */ +async function waTextAny(user, message) { + if (!user || !isValidZAPhone(user.phoneNumber)) return; + try { + await sendText(user.phoneNumber, message); + } catch (e) { + console.warn('[notify] WhatsApp text (fallback) failed:', e?.response?.data?.message || e.message); + } +} + +/** + * Like waPdf but bypasses the notification preference check. + * Use as a fallback when email is not available (guest / no valid email). + */ +async function waPdfAny(user, localPdfPath, filename, caption) { + if (!user || !isValidZAPhone(user.phoneNumber)) return; + try { + await sendPdf(user.phoneNumber, localPdfPath, filename, caption); + } catch (e) { + console.warn('[notify] WhatsApp PDF (fallback) failed:', e?.response?.data?.message || e.message); + } +} + +module.exports = { canWhatsApp, shouldEmail, waText, waPdf, waTextAny, waPdfAny }; \ No newline at end of file diff --git a/backend/src/utils/pricing.js b/backend/src/utils/pricing.js new file mode 100644 index 0000000..8724c4d --- /dev/null +++ b/backend/src/utils/pricing.js @@ -0,0 +1,248 @@ +/** + * Early-bird pricing utility + * + * Rules: + * - Base price is eventOption.price + * - resolveOptionPrice: picks the cheapest applicable tier, checking both deadline AND stock limits. + * Used at registration-creation time and again at payment-initiation time. + * - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and + * as a fallback for legacy RegistrationOption rows that have no priceSnapshot. + * - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after + * refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice. + * - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption + * that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier + * is now expired or its stock is exhausted. + */ + +const prisma = require('../config/db'); + +/** + * Compute effective unit price for an event option at a given time considering early-bird tiers. + * Only checks deadline (not stock). Used for display and as a legacy fallback. + * + * @param {object} eventOption - includes price:number and earlyBirdTiers?:Array<{deadline:string|Date, price:number}> + * @param {Date|string|null} referenceTime - usually the last payment time; if null, falls back to atTime + * @param {Date} atTime - payment/evaluation time (e.g., now or payment.createdAt) + * @returns {number} + */ +function getEffectiveUnitPrice(eventOption, referenceTime, atTime) { + if (!eventOption) return 0; + const base = Number(eventOption.price || 0); + const tiers = Array.isArray(eventOption.earlyBirdTiers) ? eventOption.earlyBirdTiers.slice() : []; + if (!tiers.length) return base; + + const t = atTime ? new Date(atTime) : new Date(); + // If no referenceTime (no payments yet), use atTime so early-bird applies based on "now" + const ref = referenceTime ? new Date(referenceTime) : t; + + // Only tiers whose deadline is after BOTH reference and payment/evaluation times qualify + const applicable = tiers + .map(x => ({ ...x, deadline: new Date(x.deadline) })) + .filter(x => (ref < x.deadline) && (t < x.deadline)) + .sort((a, b) => a.deadline.getTime() - b.deadline.getTime() || (a.order || 0) - (b.order || 0) || a.price - b.price); + + if (applicable.length === 0) return base; + const chosen = applicable[0]; + const price = Number(chosen.price); + if (!(price >= 0)) return base; + return price; +} + +/** + * Resolve the best applicable early-bird tier price for an event option. + * Checks BOTH deadline AND stock limits. Cancelled registrations are excluded from stock counts. + * Tiers are sorted cheapest-first (best deal for the user); earliest deadline breaks ties. + * + * @param {object} option - EventOption with price, earlyBirdTiers[] + * @param {number} requestedQty - quantity being purchased + * @returns {Promise<{ price: number, tierId: string|null }>} + */ +async function resolveOptionPrice(option, requestedQty = 1) { + const now = new Date(); + // Only option-level tiers (no variant association) + const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : []) + .filter(t => !t.variantId) + .slice(); + + // Sort cheapest-first; earliest deadline breaks ties + tiers.sort((a, b) => { + if (a.price !== b.price) return a.price - b.price; + return new Date(a.deadline).getTime() - new Date(b.deadline).getTime(); + }); + + for (const tier of tiers) { + // Skip expired tiers + if (now >= new Date(tier.deadline)) continue; + + // Check stock limit if one is set + if (tier.stockLimit > 0) { + const soldAgg = await prisma.registrationOption.aggregate({ + where: { + appliedTierId: tier.id, + registration: { status: { not: 'cancelled' } } + }, + _sum: { quantity: true } + }); + const tierSold = soldAgg._sum?.quantity || 0; + if (tierSold + requestedQty > tier.stockLimit) continue; // tier exhausted — try next + } + + return { price: tier.price, tierId: tier.id }; + } + + // No tier applicable — fall back to base option price + return { price: option.price, tierId: null }; +} + +/** + * Resolve the best applicable early-bird tier price for a specific variant. + * Checks tiers that have variantId matching the given variant. + * Falls back to variant.price (or option.price if variant has no override) when no tier applies. + * + * @param {object} option - EventOption with price, earlyBirdTiers[], variants[] + * @param {string} variantId + * @param {number} requestedQty + * @returns {Promise<{ price: number, tierId: string|null }>} + */ +async function resolveVariantTierPrice(option, variantId, requestedQty = 1) { + const now = new Date(); + const variant = (option.variants || []).find(v => v.id === variantId); + const basePrice = (variant && variant.price !== null && variant.price !== undefined) + ? Number(variant.price) + : Number(option.price || 0); + + const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : []) + .filter(t => t.variantId === variantId) + .slice(); + + if (!tiers.length) return { price: basePrice, tierId: null }; + + tiers.sort((a, b) => { + if (a.price !== b.price) return a.price - b.price; + return new Date(a.deadline).getTime() - new Date(b.deadline).getTime(); + }); + + for (const tier of tiers) { + if (now >= new Date(tier.deadline)) continue; + if (tier.stockLimit > 0) { + const soldAgg = await prisma.registrationOption.aggregate({ + where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } }, + _sum: { quantity: true } + }); + const tierSold = soldAgg._sum?.quantity || 0; + if (tierSold + requestedQty > tier.stockLimit) continue; + } + return { price: tier.price, tierId: tier.id }; + } + + return { price: basePrice, tierId: null }; +} + +/** + * Re-evaluate early-bird prices for all RegistrationOptions that have an appliedTierId. + * If a tier is now expired or its stock is exhausted, the next applicable tier (or base price) + * is resolved and priceSnapshot + appliedTierId are updated in the DB. + * + * Call this before processing any payment to ensure stock-based price forfeiture is enforced. + * + * @param {string} registrationId + * @returns {Promise<{ changed: boolean }>} + */ +async function refreshPricingForRegistration(registrationId) { + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true, variants: true } } + } + } + } + }); + if (!registration) return { changed: false }; + + let anyChanged = false; + + for (const ro of registration.registrationOptions) { + // Only refresh options that were priced via a tier + if (!ro.appliedTierId) continue; + + // Find the currently applied tier + const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId); + + if (currentTier && new Date() < new Date(currentTier.deadline)) { + // The tier's deadline is still in the future — honor the locked price. + continue; + } + + // Deadline has passed (or tier record missing) — resolve the next applicable tier + const resolved = ro.variantId + ? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity) + : await resolveOptionPrice(ro.eventOption, ro.quantity); + + const tierChanged = resolved.tierId !== ro.appliedTierId; + const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001; + + if (tierChanged || priceChanged) { + await prisma.registrationOption.update({ + where: { id: ro.id }, + data: { + priceSnapshot: resolved.price, + appliedTierId: resolved.tierId + } + }); + anyChanged = true; + } + } + + return { changed: anyChanged }; +} + +/** + * Compute total due for a registration at a given time. + * + * Uses priceSnapshot when present (authoritative — set at registration time and refreshed + * before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice + * for legacy rows without a snapshot. + * + * @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption} + * and optionally payments[] + * @param {Date} atTime - evaluation time (used for legacy fallback only) + * @returns {number} + */ +function computeRegistrationTotalDue(registration, atTime) { + if (!registration || !Array.isArray(registration.registrationOptions)) return 0; + + // Determine the last payment time (used only for the legacy getEffectiveUnitPrice fallback) + let lastPaymentAt = null; + try { + if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) { + lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime()))); + } + } catch {} + + return registration.registrationOptions.reduce((sum, ro) => { + const qty = Number(ro.quantity || 0); + let unit; + + if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) { + // priceSnapshot is authoritative — set at registration creation and kept current + // by refreshPricingForRegistration at payment initiation time. + unit = ro.priceSnapshot; + } else { + // Fallback: legacy row without a snapshot — re-evaluate from tier deadlines + const eo = ro.eventOption || {}; + unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime); + } + + return sum + qty * unit; + }, 0); +} + +module.exports = { + getEffectiveUnitPrice, + resolveOptionPrice, + resolveVariantTierPrice, + refreshPricingForRegistration, + computeRegistrationTotalDue, +}; \ No newline at end of file diff --git a/backend/src/utils/scheduledEmails.js b/backend/src/utils/scheduledEmails.js new file mode 100644 index 0000000..94be64c --- /dev/null +++ b/backend/src/utils/scheduledEmails.js @@ -0,0 +1,108 @@ +const fs = require('fs'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); + +const DATA_DIR = path.join(__dirname, '..', '..', 'data'); +const QUEUE_PATH = path.join(DATA_DIR, 'scheduled-emails.json'); + +function ensureStore() { + try { + if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + if (!fs.existsSync(QUEUE_PATH)) fs.writeFileSync(QUEUE_PATH, JSON.stringify({ jobs: [] }, null, 2), 'utf-8'); + } catch (e) { + // Best-effort; throws will surface to caller + } +} + +function loadAll() { + ensureStore(); + try { + const raw = fs.readFileSync(QUEUE_PATH, 'utf-8'); + const data = JSON.parse(raw); + const jobs = Array.isArray(data?.jobs) ? data.jobs : []; + return jobs; + } catch (e) { + return []; + } +} + +function saveAll(jobs) { + ensureStore(); + const payload = { jobs: Array.isArray(jobs) ? jobs : [] }; + // Simple atomic-ish write + const tmp = QUEUE_PATH + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf-8'); + fs.renameSync(tmp, QUEUE_PATH); +} + +/** + * Add a scheduled job + * @param {object} job { id?, eventId, createdById, scheduledAt: ISO string, payload: body for emailEventAttendees } + */ +function addJob(job) { + const now = new Date(); + const id = job.id || uuidv4(); + const rec = { + id, + eventId: job.eventId || null, + broadcast: !!job.broadcast, + createdById: job.createdById || null, + scheduledAt: job.scheduledAt, + createdAt: now.toISOString(), + status: 'queued', // queued | sending | sent | error + attempts: 0, + lastError: null, + payload: job.payload || {}, + }; + const jobs = loadAll(); + jobs.push(rec); + saveAll(jobs); + return rec; +} + +function listJobs(filter = {}) { + const jobs = loadAll(); + // Basic filter support + return jobs.filter(j => { + if (filter.status && j.status !== filter.status) return false; + if (filter.eventId && j.eventId !== filter.eventId) return false; + return true; + }); +} + +function getDueJobs(now = new Date()) { + const jobs = loadAll(); + const t = now instanceof Date ? now : new Date(now); + return jobs.filter(j => j.status === 'queued' && new Date(j.scheduledAt).getTime() <= t.getTime()); +} + +function updateJob(id, patch) { + const jobs = loadAll(); + const idx = jobs.findIndex(j => j.id === id); + if (idx === -1) return null; + jobs[idx] = { ...jobs[idx], ...patch }; + saveAll(jobs); + return jobs[idx]; +} + +function getJob(id) { + const jobs = loadAll(); + return jobs.find(j => j.id === id) || null; +} + +function deleteJob(id) { + const jobs = loadAll(); + const filtered = jobs.filter(j => j.id !== id); + if (filtered.length === jobs.length) return false; + saveAll(filtered); + return true; +} + +module.exports = { + addJob, + listJobs, + getDueJobs, + updateJob, + getJob, + deleteJob, +}; diff --git a/backend/src/utils/settingsCache.js b/backend/src/utils/settingsCache.js new file mode 100644 index 0000000..c3758e4 --- /dev/null +++ b/backend/src/utils/settingsCache.js @@ -0,0 +1,79 @@ +/** + * Lightweight in-process cache for AppSetting values. + * Refreshes every 60 seconds so changes made in the admin settings page + * take effect without a server restart. + * Falls back to environment variables when a key is not found in the DB. + * + * Encrypted keys (smtp_user, smtp_pass) are automatically decrypted on read. + */ + +const prisma = require('../config/db'); +const { decrypt, isEncrypted } = require('./encryption'); + +// Keys stored encrypted in the DB — decrypted transparently on read. +const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']); + +let _cache = {}; +let _lastFetch = 0; +const TTL_MS = 60 * 1000; + +async function refreshIfStale() { + const now = Date.now(); + if (now - _lastFetch < TTL_MS && _lastFetch > 0) return; + try { + const rows = await prisma.appSetting.findMany(); + const fresh = {}; + for (const r of rows) fresh[r.key] = r.value; + _cache = fresh; + _lastFetch = now; + } catch { + // DB unreachable — keep existing cache; will retry next call + } +} + +/** + * Internal getter that optionally decrypts encrypted keys. + * @param {string} key + * @param {string} [envFallback] + * @returns {string} + */ +function _getSync(key, envFallback = '') { + const v = _cache[key]; + const raw = (v !== undefined && v !== null && v !== '') ? v : (envFallback || ''); + if (ENCRYPTED_KEYS.has(key) && isEncrypted(raw)) { + try { return decrypt(raw); } catch { return ''; } + } + return raw; +} + +/** + * Async getter — always checks for a stale cache before returning. + * @param {string} key + * @param {string} [envFallback] + * @returns {Promise} + */ +async function getSetting(key, envFallback = '') { + await refreshIfStale(); + return _getSync(key, envFallback); +} + +/** + * Sync wrapper for use in synchronous functions. + * Uses the current in-memory snapshot; relies on the cache being warmed at startup. + */ +const getSettingSync = _getSync; + +/** + * Call once at startup to pre-warm the cache so synchronous callers get DB values. + */ +async function warmCache() { + await refreshIfStale(); +} + +/** Invalidate the cache and immediately re-warm it in the background. */ +function invalidate() { + _lastFetch = 0; + refreshIfStale().catch(() => {}); +} + +module.exports = { getSetting, getSettingSync, warmCache, invalidate, ENCRYPTED_KEYS }; \ No newline at end of file diff --git a/backend/src/utils/ticketUtils.js b/backend/src/utils/ticketUtils.js new file mode 100644 index 0000000..2da9ef0 --- /dev/null +++ b/backend/src/utils/ticketUtils.js @@ -0,0 +1,145 @@ +const prisma = require('../config/db'); +const { v4: uuidv4 } = require('uuid'); + +/** + * Generate tickets for a registration when payment status is "paid". + * + * Rules: + * - Exactly ONE ticket per registrationOption (keyed by eventOptionId). + * - If duplicate registrationOptions exist for the same eventOptionId (old data), + * consolidate them: merge quantities, keep the one with scan history, delete the rest. + * - If duplicate ticket records exist for the same registrationOption (old data), + * keep the primary (scanned one, or oldest), update its quantity, delete the rest. + * + * @param {string} registrationId + * @returns {Promise} newly-created ticket records (empty when all already existed) + */ +const generateTicketsForRegistration = async (registrationId) => { + try { + const registration = await prisma.registration.findUnique({ + where: { id: registrationId }, + include: { + registrationOptions: { + include: { + eventOption: true, + tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } } + } + }, + event: true, + user: true + } + }); + + if (!registration) throw new Error('Registration not found'); + if (registration.status !== 'paid') return []; + + // If event has a required form, ensure sufficient responses exist + try { + const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }); + if (form && form.isRequired) { + const requiredCount = (registration.registrationOptions || []) + .filter(ro => ro.eventOption?.isMainTicket) + .reduce((s, ro) => s + (ro.quantity || 0), 0); + const responsesCount = await prisma.formResponse.count({ where: { registrationId } }); + if (responsesCount < requiredCount) return []; + } + } catch (_) { /* form models unavailable — don't block */ } + + // ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ── + // Key on both fields so that different variants of the same option are never merged + const byOption = new Map(); + for (const ro of registration.registrationOptions) { + const key = `${ro.eventOptionId}::${ro.variantId || ''}`; + if (!byOption.has(key)) byOption.set(key, []); + byOption.get(key).push(ro); + } + + // For each group with duplicates, merge into the one that has tickets (or the first) + for (const [, group] of byOption) { + if (group.length <= 1) continue; + + // Prefer the option that already has tickets + const withTickets = group.filter(ro => (ro.tickets || []).length > 0); + const primary = withTickets.length > 0 ? withTickets[0] : group[0]; + const duplicates = group.filter(ro => ro.id !== primary.id); + + // Move all tickets from duplicates to primary, then delete duplicate options + for (const dup of duplicates) { + for (const t of (dup.tickets || [])) { + await prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } }); + } + const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0); + await prisma.registrationOption.update({ + where: { id: primary.id }, + data: { quantity: (primary.quantity || 0) + totalMergedQty, } + }); + await prisma.registrationOption.delete({ where: { id: dup.id } }); + } + + // Re-load the primary's current quantity after merge + const updated = await prisma.registrationOption.findUnique({ where: { id: primary.id } }); + primary.quantity = updated?.quantity ?? primary.quantity; + // Reload tickets + primary.tickets = await prisma.ticket.findMany({ + where: { registrationOptionId: primary.id }, + include: { usages: true }, + orderBy: { createdAt: 'asc' } + }); + } + + // ── Step 2: For each unique option, ensure exactly one ticket with correct qty ── + const generatedTickets = []; + + // Re-read fresh list (some options may have been deleted above) + const freshOptions = await prisma.registrationOption.findMany({ + where: { registrationId }, + include: { + tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } } + } + }); + + for (const option of freshOptions) { + const targetQty = option.quantity || 1; + const existingTickets = option.tickets || []; + + if (existingTickets.length === 0) { + // Create one ticket + const ticket = await prisma.ticket.create({ + data: { + id: uuidv4(), + qrCode: uuidv4(), + registrationOptionId: option.id, + userId: registration.userId, + eventId: registration.eventId, + quantity: targetQty, + updatedAt: new Date() + } + }); + generatedTickets.push(ticket); + continue; + } + + // Pick primary: prefer scanned, otherwise oldest + const withUsages = existingTickets.filter(t => (t.usages || []).length > 0); + const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0]; + + // Update quantity on primary if needed + if (primary.quantity !== targetQty) { + await prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } }); + } + + // Delete unscanned duplicates + const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0); + if (dups.length > 0) { + await prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } }); + } + } + + return generatedTickets; + } catch (error) { + console.error('Error generating tickets:', error); + throw error; + } +}; + +module.exports = { generateTicketsForRegistration }; \ No newline at end of file diff --git a/backend/src/utils/waMessages.js b/backend/src/utils/waMessages.js new file mode 100644 index 0000000..cd99fb8 --- /dev/null +++ b/backend/src/utils/waMessages.js @@ -0,0 +1,258 @@ +/** + * WhatsApp-formatted message builders. + * + * Styling reference: https://faq.whatsapp.com/539178204879377/ + * *bold* _italic_ ~strikethrough~ ```monospace``` + * > blockquote - unordered list 1. numbered list + * # Heading 1 ## Heading 2 ### Heading 3 + */ + +function fmtAmount(amt) { + return `R${Number(amt || 0).toFixed(2)}`; +} + +function fmtDateShort(d) { + try { + return new Date(d).toLocaleDateString('en-GB', { + weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', + }); + } catch { return String(d); } +} + +const { getSettingSync } = require('./settingsCache'); + +function getOrg() { + return { + name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'), + email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '', + url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''), + }; +} + +// ─── Registration confirmation ──────────────────────────────────────────────── + +/** + * @param {object} reg - full registration from loadRegistrationFull + * @param {{ isNew?: boolean, balance?: number, totalDue?: number, totalPaid?: number }} opts + */ +function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid } = {}) { + const org = getOrg(); + const eventTitle = reg.event?.title || 'the event'; + const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : ''; + const name = reg.user?.name || 'there'; + + const heading = isNew ? '🎉 *Registration Confirmed!*' : '✏️ *Registration Updated*'; + const intro = isNew + ? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.` + : `Your registration for *${eventTitle}* has been updated.`; + + const items = (reg.registrationOptions || []) + .map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`) + .join('\n'); + + const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const due = totalDue ?? 0; + const bal = balance ?? Math.max(due - paid, 0); + + const finLine = bal <= 0 + ? `✅ *Fully paid — you're all set!*` + : `*Balance due:* ${fmtAmount(bal)}\n_Pay at ${org.url} or at the door._`; + + return [ + heading, + '', + `Hi ${name},`, + '', + intro, + '', + '*Your selections:*', + items || '—', + '', + `*Total:* ${fmtAmount(due)} *Paid:* ${fmtAmount(paid)}`, + finLine, + '', + `_${org.name}_ | ${org.url}`, + ].join('\n'); +} + +// ─── Payment receipt ────────────────────────────────────────────────────────── + +function buildWAPayment(payment) { + const org = getOrg(); + const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event'; + const name = (payment.registration?.user || payment.user)?.name || 'there'; + const amount = fmtAmount(payment.amount); + + const reg = payment.registration; + let balLine = ''; + if (reg) { + const { computeRegistrationTotalDue } = require('./pricing'); + const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date()); + const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const balance = Math.max(totalDue - totalPaid, 0); + balLine = balance <= 0 + ? `\n✅ *Fully paid!* Your tickets have been sent.` + : `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`; + } + + return [ + `✅ *Payment Received*`, + '', + `Hi ${name},`, + '', + `We've received your payment of *${amount}* for *${eventTitle}*.`, + balLine, + '', + `_${org.name}_ | ${org.url}`, + ].join('\n'); +} + +// ─── Login notification ─────────────────────────────────────────────────────── + +function buildWALogin({ name, when, location, userAgent }) { + const org = getOrg(); + return [ + `🔐 *New Login Detected*`, + '', + `Hi ${name || 'there'},`, + '', + `A new login to your *${org.name}* account was detected.`, + '', + `*Time:* ${when}`, + `*Location:* ${location}`, + `*Device:* ${userAgent}`, + '', + `> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`, + '', + `_If this was you, no action is needed._`, + ].join('\n'); +} + +// ─── Welcome ────────────────────────────────────────────────────────────────── + +function buildWAWelcome({ name, events }) { + const org = getOrg(); + const hasEvents = Array.isArray(events) && events.length > 0; + + const eventsBlock = hasEvents + ? [ + '*Upcoming events:*', + ...events.map(e => + `- *${e.title}* — ${new Date(e.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` + ), + ].join('\n') + : 'Keep an eye on our website for upcoming events.'; + + return [ + `🎉 *Welcome to ${org.name}!*`, + '', + `Hi ${name || 'there'},`, + '', + `Your account is set up and ready. Use it to register for events, manage your bookings, and access your tickets.`, + '', + eventsBlock, + '', + `${org.url}`, + ].join('\n'); +} + +// ─── Account closed ─────────────────────────────────────────────────────────── + +function buildWAAccountClosed({ name, dataDeleted }) { + const org = getOrg(); + const detail = dataDeleted + ? 'All personal data associated with your account has been permanently deleted.' + : `Your account has been deactivated. To also delete your personal data, contact ${org.email}.`; + + return [ + `🔒 *Account Closed*`, + '', + `Hi ${name || 'there'},`, + '', + `Your *${org.name}* account has been successfully closed.`, + '', + detail, + '', + `_${org.name}_ | ${org.email}`, + ].join('\n'); +} + +// ─── Ticket delivery caption ────────────────────────────────────────────────── + +function buildWATicketCaption({ name, eventTitle, eventDate }) { + return [ + `🎟️ *Your tickets for ${eventTitle}*`, + '', + `Hi ${name || 'there'}! Your tickets${eventDate ? ` for *${eventDate}*` : ''} are attached.`, + 'Please show this PDF (printed or on your phone) at the event entrance.', + ].join('\n'); +} + +// ─── Refund notification ────────────────────────────────────────────────────── + +function buildWARefund(payment) { + const org = getOrg(); + const user = payment.user; + const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event'; + const amt = fmtAmount(Math.abs(payment.amount || 0)); + const name = user?.name || 'there'; + + return [ + `💸 *Refund Processed*`, + '', + `Hi ${name},`, + '', + `A refund of *${amt}* for *${eventTitle}* has been processed.`, + '', + `Refunds may take a few business days to appear depending on your bank.`, + '', + `_${org.name}_ | ${org.email}`, + ].join('\n'); +} + +// ─── Donation applied to a registration ──────────────────────────────────────── +// +// Distinct from buildWAPayment: sent to the REGISTRANT when staff apply someone else's +// donation to their registration — anonymous (no donor name), and never "payment received" +// wording since they didn't pay anything themselves. + +function buildWADonationAppliedToRegistrant(payment) { + const org = getOrg(); + const reg = payment.registration; + const eventTitle = reg?.event?.title || 'the event'; + const name = reg?.user?.name || 'there'; + const amount = fmtAmount(payment.amount); + + let balLine = ''; + if (reg) { + const { computeRegistrationTotalDue } = require('./pricing'); + const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date()); + const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + const balance = Math.max(totalDue - totalPaid, 0); + balLine = balance <= 0 + ? `\n✅ *Fully paid!* Your tickets have been sent.` + : `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`; + } + + return [ + `🎁 *Donation Applied*`, + '', + `Hi ${name},`, + '', + `A donation of *${amount}* was applied to your registration for *${eventTitle}*.`, + balLine, + '', + `_${org.name}_ | ${org.url}`, + ].join('\n'); +} + +module.exports = { + buildWARegistration, + buildWAPayment, + buildWARefund, + buildWADonationAppliedToRegistrant, + buildWALogin, + buildWAWelcome, + buildWAAccountClosed, + buildWATicketCaption, +}; \ No newline at end of file diff --git a/backend/src/utils/whatsapp.js b/backend/src/utils/whatsapp.js new file mode 100644 index 0000000..16237e6 --- /dev/null +++ b/backend/src/utils/whatsapp.js @@ -0,0 +1,294 @@ +const axios = require('axios'); +const path = require('path'); +const fs = require('fs'); +const { v4: uuidv4 } = require('uuid'); + +const BASE = 'https://api.wawp.net/v2'; + +// ─── Config cache (DB-backed, env fallback) ─────────────────────────────────── + +let _configCache = null; +let _configCacheAt = 0; +const CONFIG_TTL_MS = 30_000; // 30 s + +/** + * Load WAWP credentials from DB (AppSetting table), falling back to .env. + * Result is cached for 30 seconds so repeated calls don't hit the DB. + */ +async function getConfig() { + const now = Date.now(); + if (_configCache && (now - _configCacheAt) < CONFIG_TTL_MS) return _configCache; + + try { + const prisma = require('../config/db'); + const rows = await prisma.appSetting.findMany({ + where: { key: { in: ['WAWP_ACCESS_TOKEN', 'WAWP_INSTANCE_ID'] } }, + }); + const map = Object.fromEntries(rows.map(r => [r.key, r.value])); + _configCache = { + token: map.WAWP_ACCESS_TOKEN || process.env.WAWP_ACCESS_TOKEN || '', + instanceId: map.WAWP_INSTANCE_ID || process.env.WAWP_INSTANCE_ID || '', + }; + } catch { + // DB unavailable — fall back to env vars + _configCache = { + token: process.env.WAWP_ACCESS_TOKEN || '', + instanceId: process.env.WAWP_INSTANCE_ID || '', + }; + } + _configCacheAt = now; + return _configCache; +} + +/** Save WAWP credentials to DB and invalidate the cache. */ +async function setConfig(token, instanceId) { + const prisma = require('../config/db'); + await prisma.$transaction([ + prisma.appSetting.upsert({ + where: { key: 'WAWP_ACCESS_TOKEN' }, + update: { value: token }, + create: { key: 'WAWP_ACCESS_TOKEN', value: token }, + }), + prisma.appSetting.upsert({ + where: { key: 'WAWP_INSTANCE_ID' }, + update: { value: instanceId }, + create: { key: 'WAWP_INSTANCE_ID', value: instanceId }, + }), + ]); + _configCache = null; // force reload on next getConfig() +} + +async function isConfigured() { + const { token, instanceId } = await getConfig(); + return !!(token && instanceId); +} + +// ─── SA phone normalisation ─────────────────────────────────────────────────── + +/** + * Normalises any common South African phone format to 27xxxxxxxxx (11 digits). + * Handles: 0821234567 / 082 123 4567 / +27821234567 / +27 82 123 4567 + * Returns null when the number cannot be resolved to a valid SA mobile. + */ +function normalizeZAPhone(raw) { + if (!raw) return null; + let digits = String(raw).replace(/\D/g, ''); + + // Local format: leading 0 + 9 digits (total 10) + if (digits.startsWith('0') && digits.length === 10) { + digits = '27' + digits.slice(1); + } + + // Must now be exactly 11 digits starting with 27 + if (/^27\d{9}$/.test(digits)) return digits; + return null; +} + +/** Returns true when raw is a valid SA mobile number. */ +function isValidZAPhone(raw) { + return normalizeZAPhone(raw) !== null; +} + +/** Converts a phone number to the WAWP chatId format (e.g. 27821234567@c.us). */ +function toChatId(raw) { + const n = normalizeZAPhone(raw); + return n ? `${n}@c.us` : null; +} + +// ─── "Session not found" auto-recovery ─────────────────────────────────────── + +/** + * Returns true when the WAWP error message indicates the session doesn't exist. + */ +function isSessionNotFound(e) { + const msg = (e?.response?.data?.message || e?.message || '').toLowerCase(); + return msg.includes('session not found') || msg.includes('instance not found'); +} + +/** + * If the WAWP API reports "Session not found", clear the stale instance ID + * from the DB so the admin UI drops back to the Session Instance setup step. + * + * @param {Error} e - The error thrown by a WAWP API call + */ +async function handleSessionNotFound(e) { + if (!isSessionNotFound(e)) throw e; // not our problem — re-throw + + console.warn('[whatsapp] Session not found — clearing instance ID from DB...'); + const { token, instanceId } = await getConfig(); + + // Try to delete the stale session on WAWP (may fail — that's okay) + try { + await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId }); + } catch (delErr) { + console.warn('[whatsapp] Delete stale session failed (ignored):', delErr?.response?.data?.message || delErr.message); + } + + // Clear the instance ID from DB so the frontend goes back to Step 2 + await setConfig(token, ''); + console.info('[whatsapp] Instance ID cleared — admin must set up a new session instance.'); + + throw new Error('SESSION_NOT_FOUND'); +} + +// ─── Session management ─────────────────────────────────────────────────────── + +async function getStatus() { + const { token, instanceId } = await getConfig(); + try { + const res = await axios.post(`${BASE}/session/info`, { access_token: token, instance_id: instanceId }); + return res.data; + } catch (e) { + await handleSessionNotFound(e); + } +} + +async function startSession() { + const { token, instanceId } = await getConfig(); + try { + const res = await axios.post(`${BASE}/session/start`, { access_token: token, instance_id: instanceId }); + return res.data; + } catch (e) { + await handleSessionNotFound(e); + } +} + +async function restartSession() { + const { token, instanceId } = await getConfig(); + try { + const res = await axios.post(`${BASE}/session/restart`, { access_token: token, instance_id: instanceId }); + return res.data; + } catch (e) { + await handleSessionNotFound(e); + } +} + +async function logoutSession() { + const { token, instanceId } = await getConfig(); + const res = await axios.post(`${BASE}/session/logout`, { access_token: token, instance_id: instanceId }); + return res.data; +} + +async function createInstance(name) { + const { token } = await getConfig(); + const label = name || `hope-events-${Date.now()}`; + const res = await axios.post(`${BASE}/session/create`, { access_token: token, name: label }); + const newInstanceId = res.data?.instance_id || res.data?.id; + if (!newInstanceId) throw new Error('Create instance returned no instance_id'); + await setConfig(token, newInstanceId); + return { ...res.data, instance_id: newInstanceId }; +} + +async function deleteInstance() { + const { token, instanceId } = await getConfig(); + if (!instanceId) throw new Error('No instance configured'); + const res = await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId }); + // Clear instance_id from DB after deletion + await setConfig(token, ''); + return res.data; +} + +async function getQr() { + const { token, instanceId } = await getConfig(); + try { + const res = await axios.post(`${BASE}/auth/qr-image`, { access_token: token, instance_id: instanceId }); + return res.data; // { qr: 'data:image/png;base64,...' } + } catch (e) { + await handleSessionNotFound(e); + } +} + +async function requestPairingCode(phoneNumber) { + const { token, instanceId } = await getConfig(); + const normalized = normalizeZAPhone(phoneNumber); + if (!normalized) throw new Error('Invalid South African phone number'); + try { + const res = await axios.post(`${BASE}/auth/request-code`, { + access_token: token, + instance_id: instanceId, + phone_number: normalized, + }); + return res.data; // { code: 'ABCD-1234' } + } catch (e) { + await handleSessionNotFound(e); + } +} + +// ─── Messaging ──────────────────────────────────────────────────────────────── + +async function sendText(toPhone, message) { + if (!(await isConfigured())) return; + const chatId = toChatId(toPhone); + if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; } + const { token, instanceId } = await getConfig(); + await axios.post(`${BASE}/send/text`, { + access_token: token, + instance_id: instanceId, + chatId, + message, + }); +} + +/** + * Copies a local PDF to a temporary public URL, sends it via WAWP, + * then schedules the temp file for deletion after 5 minutes. + */ +async function sendPdf(toPhone, localPdfPath, filename, caption) { + if (!(await isConfigured())) return; + const chatId = toChatId(toPhone); + if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping PDF:', toPhone); return; } + + const tempDir = path.join(__dirname, '../../public/uploads/tickets-temp'); + if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); + + const tempName = `${uuidv4()}.pdf`; + const tempPath = path.join(tempDir, tempName); + fs.copyFileSync(localPdfPath, tempPath); + + const backendUrl = (process.env.BACKEND_URL || '').replace(/\/$/, ''); + const isLocalhost = !backendUrl || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(backendUrl); + if (isLocalhost) { + try { fs.unlinkSync(tempPath); } catch {} + throw new Error( + `WhatsApp PDF delivery requires a publicly accessible backend URL. ` + + `BACKEND_URL is currently "${backendUrl || '(not set)'}". ` + + `Set BACKEND_URL to your public backend URL (e.g. https://api.yourdomain.com) in your .env file.` + ); + } + const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`; + + const { token, instanceId } = await getConfig(); + await axios.post(`${BASE}/send/pdf`, { + access_token: token, + instance_id: instanceId, + chatId, + file: { + url: pdfUrl, + filename: filename || 'tickets.pdf', + mimetype: 'application/pdf', + }, + caption: caption || '', + }); + + // Clean up after 5 minutes — WAWP will have fetched the file by then + setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000); +} + +module.exports = { + getConfig, + setConfig, + normalizeZAPhone, + isValidZAPhone, + toChatId, + isConfigured, + getStatus, + startSession, + restartSession, + logoutSession, + createInstance, + deleteInstance, + getQr, + requestPairingCode, + sendText, + sendPdf, +}; \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..f3bb132 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,318 @@ +# Hope Events — Frontend + +Next.js 15 (App Router) frontend for the Hope Events platform used by Hope Family Church (`events.hopehenley.co.za`). + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Tech Stack](#tech-stack) +3. [Prerequisites](#prerequisites) +4. [Installation](#installation) +5. [Environment Variables](#environment-variables) +6. [Running the App](#running-the-app) +7. [Project Structure](#project-structure) +8. [Pages & Routes](#pages--routes) +9. [Authentication & Roles](#authentication--roles) +10. [Payment Flow](#payment-flow) +11. [Notifications](#notifications) +12. [Deployment](#deployment) +13. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +The frontend handles: +- Public event listings and event detail pages +- User registration, login, and account management +- Attendee registration and Yoco checkout +- Ticket viewing and delivery +- Staff ticket scanning (QR scanner) +- Supervisor tools: event management, payments, bulk messaging (email + WhatsApp), reports, at-the-door check-in +- Admin tools: user management, WhatsApp instance management, form builder + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Framework | Next.js 15 (App Router, React 19) | +| Language | TypeScript | +| Styling | Tailwind CSS 3 | +| UI Primitives | Radix UI | +| Forms | React Hook Form + Zod | +| Icons | Lucide React | +| QR Scanning | `@zxing/browser`, `react-webcam` | +| QR Generation | `qrcode` | +| Date handling | `date-fns`, `react-day-picker` | + +--- + +## Prerequisites + +- Node.js 18+ +- npm +- A running backend API (see `../backend/README.md`) + +--- + +## Installation + +```bash +cd frontend +npm install +``` + +--- + +## Environment Variables + +Create `frontend/.env.local`: + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_API_URL` | Yes | Base URL of the backend API, e.g. `https://api.yourdomain.com` or `http://localhost:5000` for local dev | + +> **Organisation name, brand colour, logo, and contact email** are now stored in the database and managed via **Admin → Site Settings**. `NEXT_PUBLIC_APP_NAME`, `NEXT_PUBLIC_ORG_NAME`, `NEXT_PUBLIC_BRAND_COLOR`, and `NEXT_PUBLIC_CONTACT_EMAIL` are no longer needed and can be removed from your `.env.local`. + +--- + +## Running the App + +```bash +# Development (Turbopack) +npm run dev + +# Production build + start +npm run build +npm start + +# Lint +npm run lint +``` + +--- + +## Project Structure + +``` +frontend/src/ +├── app/ # Next.js App Router pages +│ ├── (auth)/ # Login, register, forgot/reset password +│ ├── [redirectUrl]/ # Event alias redirects +│ ├── activate-account/ # Account activation from email link +│ ├── dashboard/ +│ │ ├── layout.tsx # Shared dashboard shell + nav +│ │ ├── admin/ # Admin-only pages +│ │ ├── supervisor/ # Supervisor + admin pages +│ │ ├── staff/ # Staff + above pages +│ │ └── user/ # All authenticated users +│ ├── events/ # Public event listing + detail +│ ├── forms/ # Public form submission +│ ├── legal/ # Privacy policy, terms +│ ├── payment/ # Payment success/failure/cancel +│ ├── register/[eventId]/ # Event registration flow +│ ├── registration/ # Post-registration success +│ ├── reset-password/ # Password reset (token flow) +│ ├── self-service/ # Guest self-service portal +│ ├── set-banner/ # Quick banner editor (supervisor) +│ └── layout.tsx # Root layout +├── components/ # Shared UI components +├── hooks/ # Custom React hooks (useAuth, etc.) +└── lib/ # Utilities (api.ts fetch wrapper, etc.) +``` + +--- + +## Pages & Routes + +### Public + +| Route | Description | +|-------|-------------| +| `/` | Landing page | +| `/events` | Public event listing — shows "Sold Out" button when all limited-stock options are exhausted; free events show "Register" without a price suffix | +| `/events/[id]` | Event detail page — sold-out/registration-closed/not-yet-open states; zero-price options display "Free"; tiered low-stock badges (≤ 50 tickets: 20 %; 51–200: 15 %; 201–1 000: 10 %; 1 000+: 5 %) | +| `/[redirectUrl]` | Event alias redirect | +| `/register/[eventId]` | Registration flow — sold-out banner + disabled button when event is fully booked; tiered stock badges; variant qty controls; early-bird notice | +| `/forms` | Public form submission | +| `/legal/privacy` | Privacy policy | +| `/legal/terms` | Terms of service | +| `/lockdown-rules` | House rules page | +| `/payment/success` | Yoco checkout success | +| `/payment/failure` | Yoco checkout failure | +| `/payment/cancel` | Yoco checkout cancelled | +| `/registration/success` | Post-registration confirmation | +| `/self-service` | Guest self-service (find tickets by email) | +| `/activate-account` | Account activation via email link | +| `/reset-password` | Password reset (token from email) | + +### Auth + +| Route | Description | +|-------|-------------| +| `/login` | Login form | +| `/register` | Sign-up form | +| `/forgot-password` | Request password reset | + +### User Dashboard — `/dashboard/user/` + +| Route | Description | +|-------|-------------| +| `/dashboard/user` | My registrations + tickets overview | +| `/dashboard/user/profile` | Edit profile, notification preferences | +| `/dashboard/user/pay` | Pay outstanding balance | +| `/dashboard/user/donate` | Make a donation | +| `/dashboard/user/forms` | Complete registration forms | +| `/dashboard/user/reset-password` | Change password (authenticated) | + +### Staff Dashboard — `/dashboard/staff/` + +| Route | Description | +|-------|-------------| +| `/dashboard/staff` | Staff overview | +| `/dashboard/staff/ticket-scanning` | QR code scanner for check-in | +| `/dashboard/staff/event-tickets` | All tickets for an event | + +### Supervisor Dashboard — `/dashboard/supervisor/` + +| Route | Description | +|-------|-------------| +| `/dashboard/supervisor` | Supervisor overview + quick links | +| `/dashboard/supervisor/events` | Create/edit/manage events — 6-step wizard (Basic Details, Items & Pricing, Sections, Form, Visibility, Attachments); form builder with up/down reorder, type badges, separate shortcut buttons for statement/heading fields; early-bird tiers with labelled deadline/price inputs; event image file upload with preview; attachments can be staged during creation (uploaded after save) | +| `/dashboard/supervisor/event-options` | Manage ticket types / options | +| `/dashboard/supervisor/payments` | View and manage payments | +| `/dashboard/supervisor/manual` | Manual payment entry | +| `/dashboard/supervisor/manual-registration` | Register an attendee manually | +| `/dashboard/supervisor/at-the-door` | At-the-door check-in management | +| `/dashboard/supervisor/email-attendees` | Bulk email + automations + broadcasts + scheduled | +| `/dashboard/supervisor/whatsapp-attendees` | Bulk WhatsApp + automations + broadcasts + scheduled | +| `/dashboard/supervisor/reports` | Generate/download/email reports | +| `/dashboard/supervisor/forms` | Event form builder and response viewer — three modes: view responses (printable, page-break per submission), fill/edit responses, edit form structure; improved form builder with reorder buttons and type badges | +| `/dashboard/supervisor/sections` | Manage event sections | +| `/set-banner` | Quick banner editor | + +### Admin Dashboard — `/dashboard/admin/` + +| Route | Description | +|-------|-------------| +| `/dashboard/admin` | Admin overview | +| `/dashboard/admin/users` | User management — list, inline edit, deactivate, role assignment, per-page selector (10/25/50/100), server-side search/filter by name/email/phone/role/status, "Delete data" button to anonymise a user's personal information | +| `/dashboard/admin/registrations` | All registrations — filter by event, status, and fuzzy text search; expandable rows showing ticket options and form responses | +| `/dashboard/admin/whatsapp` | WhatsApp instance management (WAWP) | +| `/dashboard/admin/forms` | View all form responses (inherits supervisor/forms) | +| `/dashboard/admin/settings` | Site Settings — organisation details, logo, brand colour, notification emails, SMTP delivery (with test connection; friendly errors + collapsible raw details on failure), legal page content | + +### Setup — `/setup` + +| Route | Description | +|-------|-------------| +| `/setup` | First-time setup wizard — shown automatically on a fresh deployment (empty database). Creates the first admin account and initial site settings in three steps: organisation details, admin account, branding. After completion, redirected to login. | + +--- + +## Authentication & Roles + +Auth state is managed via the `useAuth` hook (`src/hooks/useAuth.ts`). The JWT is stored in `localStorage` and sent as a `Bearer` token on every API call via `src/lib/api.ts` (`apiFetch`). + +### Role access matrix + +| Page group | `user` | `staff` | `supervisor` | `admin` | +|------------|--------|---------|--------------|---------| +| Public pages | Yes | Yes | Yes | Yes | +| `/dashboard/user/*` | Yes | Yes | Yes | Yes | +| `/dashboard/staff/*` | — | Yes | Yes | Yes | +| `/dashboard/supervisor/*` | — | — | Yes | Yes | +| `/dashboard/admin/*` | — | — | — | Yes | + +Unauthenticated users are redirected to `/login`. Insufficient-role users see an in-page warning. + +### Notification preferences + +Users set their preference on the profile page: +- `email` — email notifications only +- `whatsapp` — WhatsApp notifications only +- `both` — both channels + +The supervisor bulk-messaging pages show preference indicators in user/attendee dropdowns: +- **Indigo badge** — email preference matches (email/both) on the email page +- **Green badge** — WhatsApp preference matches (whatsapp/both) on the WA page +- **Amber badge** — preference mismatch (will still receive message) + +A "Select Email/both" or "Select WhatsApp/both" quick-select button is available in each dropdown. + +--- + +## Payment Flow + +1. User registers for a paid event → registration created with `status: pending` +2. User clicks "Pay" → calls `POST /api/payments/yoco-checkout` +3. Backend returns a Yoco `redirectUrl` → frontend opens it in a new tab +4. User pays on Yoco's hosted page → Yoco sends webhook to backend +5. Backend reconciles payment, updates registration status, generates and emails tickets +6. User sees `/payment/success` and tickets appear in their dashboard + +Outstanding balances can be paid at any time from `/dashboard/user/pay`. + +--- + +## Notifications + +### Bulk Email / WhatsApp (`email-attendees`, `whatsapp-attendees`) + +Both pages share the same feature set: + +- **Attendees tab** — send or schedule a message to attendees of a specific event; filter by payment status; templates: custom, payment reminder, event reminder, ticket delivery +- **Automations tab** — schedule pre-event (1 week), final reminder (24/48 h), thank-you, and promo messages with smart default timing based on the event's dates +- **Broadcasts tab** — send to a selected user list plus ad-hoc email addresses or phone numbers +- **Scheduled tab** — view, edit (reschedule / update content), or cancel queued jobs + +Mismatch warnings appear when selected users prefer a different channel. Falling back to all attendees if no matching-preference attendees exist. + +The attendee selection dropdown is constrained to a fixed width (`max-w-xs` / `w-64` panel) so it does not grow to fill the page on wide screens. + +--- + +## Deployment + +### Vercel (recommended) +1. Push the `frontend/` directory to GitHub +2. Create a Vercel project pointing to `frontend/` +3. Set env var: `NEXT_PUBLIC_API_URL=https://your-api.domain.com` +4. Deploy + +### Self-hosted (Node.js) +```bash +npm run build +npm start # port 3000 by default +``` + +Use nginx/Caddy as a reverse proxy. + +### PM2 +```bash +pm2 start npm --name hope-events-frontend -- start +pm2 save && pm2 startup +``` + +### Checklist +- [ ] `NEXT_PUBLIC_API_URL` set to production backend URL +- [ ] Backend `FRONTEND_URL` includes this frontend's origin +- [ ] Yoco webhook URL reachable over HTTPS +- [ ] `npm run build` completes without errors + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| 4xx/5xx API calls | Check `NEXT_PUBLIC_API_URL` is correct and reachable from the browser | +| CORS errors | Confirm backend `FRONTEND_URL` includes your frontend origin | +| Port conflict in dev | Next.js auto-selects 3001+ if 3000 is taken — keep `NEXT_PUBLIC_API_URL` pointing at the backend port | +| Images not showing | Backend must serve `/uploads` and `public/uploads` must be writable | +| Login loop | Clear `localStorage` and re-login; check `JWT_SECRET` on backend hasn't changed | +| QR scanner not working | Browser requires camera permission; `@zxing/browser` requires HTTPS in production | \ No newline at end of file diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..3ad470b --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..c85fb67 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..04bfdbb --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,15 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + images: { + domains: ['images.unsplash.com'], + }, + eslint: { + ignoreDuringBuilds: true, + }, + compiler: { + removeConsole: process.env.NODE_ENV === 'production', + }, +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c123363 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,8232 @@ +{ + "name": "hope-events-frontend", + "version": "1.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hope-events-frontend", + "version": "1.2", + "dependencies": { + "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-tooltip": "^1.2.7", + "@zxing/browser": "^0.1.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.536.0", + "next": "15.4.5", + "qrcode": "^1.5.4", + "react": "19.1.0", + "react-day-picker": "^9.8.1", + "react-dom": "19.1.0", + "react-hook-form": "^7.62.0", + "react-webcam": "^7.2.0", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.0.15" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@types/node": "^20", + "@types/qrcode": "^1.5.5", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "eslint": "^9", + "eslint-config-next": "15.4.5", + "postcss": "^8.5.6", + "tailwindcss": "3.4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.3.1.tgz", + "integrity": "sha512-LnBOyuj+piItX/D5BWBSckBsuZyOt7Jg2obGNiObq7qjl1A2/8F+i4RS8/MmkSdnw6hOe6afrJLCWrUWZw5Mlw==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", + "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", + "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", + "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz", + "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz", + "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.3" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.1.tgz", + "integrity": "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.5.tgz", + "integrity": "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.4.5.tgz", + "integrity": "sha512-YhbrlbEt0m4jJnXHMY/cCUDBAWgd5SaTa5mJjzOt82QwflAFfW/h3+COp2TfVSzhmscIZ5sg2WXt3MLziqCSCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.5.tgz", + "integrity": "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.5.tgz", + "integrity": "sha512-CL6mfGsKuFSyQjx36p2ftwMNSb8PQog8y0HO/ONLdQqDql7x3aJb/wB+LA651r4we2pp/Ck+qoRVUeZZEvSurA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.5.tgz", + "integrity": "sha512-1hTVd9n6jpM/thnDc5kYHD1OjjWYpUJrJxY4DlEacT7L5SEOXIifIdTye6SQNNn8JDZrcN+n8AWOmeJ8u3KlvQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.5.tgz", + "integrity": "sha512-4W+D/nw3RpIwGrqpFi7greZ0hjrCaioGErI7XHgkcTeWdZd146NNu1s4HnaHonLeNTguKnL2Urqvj28UJj6Gqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.5.tgz", + "integrity": "sha512-N6Mgdxe/Cn2K1yMHge6pclffkxzbSGOydXVKYOjYqQXZYjLCfN/CuFkaYDeDHY2VBwSHyM2fUjYBiQCIlxIKDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.5.tgz", + "integrity": "sha512-YZ3bNDrS8v5KiqgWE0xZQgtXgCTUacgFtnEgI4ccotAASwSvcMPDLua7BWLuTfucoRv6mPidXkITJLd8IdJplQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.5.tgz", + "integrity": "sha512-9Wr4t9GkZmMNcTVvSloFtjzbH4vtT4a8+UHqDoVnxA5QyfWe6c5flTH1BIWPGNWSUlofc8dVJAE7j84FQgskvQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.5.tgz", + "integrity": "sha512-voWk7XtGvlsP+w8VBz7lqp8Y+dYw/MTI4KeS0gTVtfdhdJ5QwhXLmNrndFOin/MDoCvUaLWMkYKATaCoUkt2/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.11.tgz", + "integrity": "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collapsible": "1.1.11", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz", + "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz", + "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz", + "integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz", + "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", + "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.5.tgz", + "integrity": "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz", + "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", + "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", + "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", + "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", + "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", + "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz", + "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/type-utils": "8.39.0", + "@typescript-eslint/utils": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.39.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz", + "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz", + "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.39.0", + "@typescript-eslint/types": "^8.39.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz", + "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz", + "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz", + "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/utils": "8.39.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz", + "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz", + "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.39.0", + "@typescript-eslint/tsconfig-utils": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz", + "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz", + "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@zxing/browser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz", + "integrity": "sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==", + "license": "MIT", + "optionalDependencies": { + "@zxing/text-encoding": "^0.9.0" + }, + "peerDependencies": { + "@zxing/library": "^0.21.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", + "integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==", + "license": "MIT", + "peer": true, + "dependencies": { + "ts-custom-error": "^3.2.1" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "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==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "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==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "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": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "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==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "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/chokidar/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==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/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/cliui/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/cliui/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/cliui/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/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.197", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.197.tgz", + "integrity": "sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", + "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.32.0", + "@eslint/plugin-kit": "^0.3.4", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.4.5.tgz", + "integrity": "sha512-IMijiXaZ43qFB+Gcpnb374ipTKD8JIyVNR+6VsifFQ/LHyx+A9wgcgSIhCX5PYSjwOoSYD5LtNHKlM5uc23eww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.4.5", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/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/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/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/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/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/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-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "dev": true, + "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-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "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/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", + "optional": true + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/lucide-react": { + "version": "0.536.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz", + "integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", + "integrity": "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "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/next": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/next/-/next-15.4.5.tgz", + "integrity": "sha512-nJ4v+IO9CPmbmcvsPebIoX3Q+S7f6Fu08/dEWu0Ttfa+wVwQRh9epcmsyCPjmL2b8MxC+CkBR97jgDhUUztI3g==", + "license": "MIT", + "dependencies": { + "@next/env": "15.4.5", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.4.5", + "@next/swc-darwin-x64": "15.4.5", + "@next/swc-linux-arm64-gnu": "15.4.5", + "@next/swc-linux-arm64-musl": "15.4.5", + "@next/swc-linux-x64-gnu": "15.4.5", + "@next/swc-linux-x64-musl": "15.4.5", + "@next/swc-win32-arm64-msvc": "15.4.5", + "@next/swc-win32-x64-msvc": "15.4.5", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.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", + "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/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "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/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.8.1.tgz", + "integrity": "sha512-kMcLrp3PfN/asVJayVv82IjF3iLOOxuH5TNFWezX6lS/T8iVRFPTETpHl3TUSTH99IDMZLubdNPJr++rQctkEw==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.2.0", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-webcam": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/react-webcam/-/react-webcam-7.2.0.tgz", + "integrity": "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.2.0", + "react-dom": ">=16.2.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "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-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "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", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "devOptional": true, + "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/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.4", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" + } + }, + "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==", + "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==", + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "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/string-width-cjs/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/string-width-cjs/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/string-width-cjs/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/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "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-ansi-cjs/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/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob/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==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.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-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "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==", + "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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/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/yargs/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/yargs/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==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/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==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/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==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/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==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/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/yargs/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/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.15.tgz", + "integrity": "sha512-2IVHb9h4Mt6+UXkyMs0XbfICUh1eUrlJJAOupBHUhLRnKkruawyDddYRCs0Eizt900ntIMk9/4RksYl+FgSpcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e60cd77 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,59 @@ +{ + "name": "hope-events-frontend", + "version": "1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-tooltip": "^1.2.7", + "@zxing/browser": "^0.1.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.536.0", + "next": "15.4.5", + "qrcode": "^1.5.4", + "react": "19.1.0", + "react-day-picker": "^9.8.1", + "react-dom": "19.1.0", + "react-hook-form": "^7.62.0", + "react-webcam": "^7.2.0", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.0.15" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@types/node": "^20", + "@types/qrcode": "^1.5.5", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "eslint": "^9", + "eslint-config-next": "15.4.5", + "postcss": "^8.5.6", + "tailwindcss": "3.4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..a16f977 --- /dev/null +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,54 @@ +"use client"; +import { useState } from "react"; +import { Navbar } from "@/components/layout/Navbar"; +import { Footer } from "@/components/layout/Footer"; +import { apiFetch } from "@/lib/api"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) return; + try { + setLoading(true); + setStatus(null); + const res: any = await apiFetch("/api/users/forgot", { method: "POST", body: { email } }); + setStatus(res?.message || "If that email exists, a password reset link has been sent."); + } catch (err: any) { + try { + const parsed = JSON.parse(err?.message || ""); + setStatus(parsed?.message || "Something went wrong"); + } catch { + setStatus(err?.message || "Something went wrong"); + } + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+

Forgot your password?

+

Enter your account email and we'll send you a link to reset your password.

+
+
+ + setEmail(e.target.value)} required className="w-full border rounded px-3 py-2" /> +
+ +
+ {status &&

{status}

} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..ea3afae --- /dev/null +++ b/frontend/src/app/(auth)/login/page.tsx @@ -0,0 +1,22 @@ +"use client"; +import React, { Suspense } from "react"; +import { Navbar } from "@/components/layout/Navbar"; +import { Footer } from "@/components/layout/Footer"; +import { LoginForm } from "@/components/auth/LoginForm"; + +export default function LoginPage() { + return ( +
+ +
+
+

Login

+ }> + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..231f2e2 --- /dev/null +++ b/frontend/src/app/(auth)/register/page.tsx @@ -0,0 +1,22 @@ +"use client"; +import React, { Suspense } from "react"; +import { Navbar } from "@/components/layout/Navbar"; +import { Footer } from "@/components/layout/Footer"; +import { RegisterForm } from "@/components/auth/RegisterForm"; + +export default function RegisterPage() { + return ( +
+ +
+
+

Create Account

+ }> + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/[redirectUrl]/page.tsx b/frontend/src/app/[redirectUrl]/page.tsx new file mode 100644 index 0000000..7c7fb55 --- /dev/null +++ b/frontend/src/app/[redirectUrl]/page.tsx @@ -0,0 +1,45 @@ +import { redirect } from "next/navigation"; +import { apiFetch } from "@/lib/api"; + +export const revalidate = 60; + +export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) { + const { redirectUrl } = await params; + + let event: any = null; + + try { + event = await apiFetch(`/api/events/by-alias/${redirectUrl}`); + } catch (error) { + console.error("Failed to fetch event:", error); + } + + if (!event || event.message?.toLowerCase().includes("not found")) { + return ( +
+

404

+

Page Not Found

+

+ The page you're looking for doesn't exist. +

+ + Back to Home → + +
+ ); + } + + if (event.isLive || event.isActive) { + redirect(`/events/${event.id}`); + } + + return ( +
+

This event has ended

+

Thanks for joining us! Stay tuned for the next one.

+ + Back to Home → + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/activate-account/page.tsx b/frontend/src/app/activate-account/page.tsx new file mode 100644 index 0000000..598c990 --- /dev/null +++ b/frontend/src/app/activate-account/page.tsx @@ -0,0 +1,79 @@ +"use client"; +import React, { Suspense, useMemo, useState } from "react"; +import { Navbar } from "@/components/layout/Navbar"; +import { Footer } from "@/components/layout/Footer"; +import { apiFetch } from "@/lib/api"; +import { useSearchParams, useRouter } from "next/navigation"; +import { appName } from "@/lib/siteConfig"; + +function ActivateAccountContent() { + const search = useSearchParams(); + const router = useRouter(); + const token = search.get("token") || ""; + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + + const canSubmit = useMemo(() => password.length >= 8 && password === confirm && !!token, [password, confirm, token]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + try { + setLoading(true); + setStatus(null); + const data = await apiFetch("/api/users/activate", { method: "POST", body: { token, password } }); + // Auto-login: save token and redirect to dashboard + if (data?.token) { + localStorage.setItem("token", data.token); + } + setStatus("Your account is now active. Redirecting…"); + setTimeout(() => router.push("/dashboard"), 1500); + } catch (err: any) { + setStatus(err?.message || "Activation failed. The link may have expired."); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+

Activate your account

+

Set a password to activate your {appName} account.

+ {!token &&

Missing or invalid activation link.

} +
+
+ + setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" /> +

At least 8 characters.

+
+
+ + setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" /> +
+ {password && confirm && password !== confirm && ( +

Passwords do not match.

+ )} + +
+ {status &&

{status}

} +
+
+
+
+ ); +} + +export default function ActivateAccountPage() { + return ( + Loading...}> + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/church_logo.jpg b/frontend/src/app/church_logo.jpg new file mode 100644 index 0000000..5bfb94d Binary files /dev/null and b/frontend/src/app/church_logo.jpg differ diff --git a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx new file mode 100644 index 0000000..17d4409 --- /dev/null +++ b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx @@ -0,0 +1,505 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useAuth } from "@/hooks/useAuth"; +import { apiFetch } from "@/lib/api"; +import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types"; + +const METHOD_LABELS: Record = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" }; +const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"]; +const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1]; + +function money(n: number | null | undefined): string { + return `R${Number(n || 0).toFixed(2)}`; +} +function denomLabel(v: number): string { + return v >= 1 ? `R${v}` : `${Math.round(v * 100)}c`; +} + +type LineInput = { actualAmount: string; notes: string }; + +export default function EventCashupPage() { + const params = useParams<{ id: string }>(); + const eventId = params.id; + const router = useRouter(); + const { token } = useAuth(); + + const [tab, setTab] = useState<"costs" | "reconciliation">("costs"); + const [data, setData] = useState(null); + const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const isClosed = data?.event?.cashupStatus === "closed"; + + const load = async () => { + if (!token || !eventId) return; + setLoading(true); + setError(null); + try { + const [financials, event] = await Promise.all([ + apiFetch(`/api/cashups/event/${eventId}`, { authToken: token }), + apiFetch(`/api/events/${eventId}`, { authToken: token }) + ]); + setData(financials); + setEventOptions((event.eventOptions || []).map((o: any) => ({ id: o.id, name: o.name }))); + } catch (e: any) { + setError(e?.message || "Failed to load cashup data"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [token, eventId]); + + return ( +
+
+
+ +

{data?.event?.title || "Event"} — Cashup

+
+ + {isClosed ? "Closed" : "Open"} + +
+ + {error &&
{error}
} + +
+ + +
+ + {loading &&
Loading…
} + + {!loading && data && tab === "costs" && ( + + )} + + {!loading && data && tab === "reconciliation" && ( + + )} +
+ ); +} + +// ─── Costs tab ───────────────────────────────────────────────────────────── + +function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }: { + eventId: string; token: string; costs: EventCost[]; eventOptions: { id: string; name: string }[]; isClosed: boolean; onChanged: () => void; +}) { + const [editingId, setEditingId] = useState(null); + const [label, setLabel] = useState(""); + const [costType, setCostType] = useState("once_off"); + const [amount, setAmount] = useState(""); + const [eventOptionId, setEventOptionId] = useState(""); + const [paidFromMethod, setPaidFromMethod] = useState<"" | CashupMethod>(""); + const [notes, setNotes] = useState(""); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + + const startNew = () => { + setEditingId("new"); + setLabel(""); setCostType("once_off"); setAmount(""); setEventOptionId(""); setPaidFromMethod(""); setNotes(""); + setErr(null); + }; + + const startEdit = (c: EventCost) => { + setEditingId(c.id); + setLabel(c.label); setCostType(c.costType); setAmount(String(c.amount)); setEventOptionId(c.eventOptionId || ""); setPaidFromMethod(c.paidFromMethod || ""); setNotes(c.notes || ""); + setErr(null); + }; + + const cancel = () => setEditingId(null); + + const save = async () => { + if (!label.trim()) { setErr("Label is required"); return; } + if (!amount || isNaN(parseFloat(amount))) { setErr("Amount is required"); return; } + if (costType === "per_item" && !eventOptionId) { setErr("Select a ticket type for per-item costs"); return; } + setSaving(true); + setErr(null); + try { + const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: paidFromMethod || null, notes: notes || null }; + if (editingId === "new") { + await apiFetch(`/api/events/${eventId}/costs`, { method: "POST", authToken: token, body }); + } else { + await apiFetch(`/api/costs/${editingId}`, { method: "PUT", authToken: token, body }); + } + setEditingId(null); + onChanged(); + } catch (e: any) { + setErr(e?.message || "Failed to save cost"); + } finally { + setSaving(false); + } + }; + + const remove = async (id: string) => { + if (!confirm("Delete this cost?")) return; + try { + await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token }); + onChanged(); + } catch (e: any) { + alert(e?.message || "Failed to delete cost"); + } + }; + + const totalCosts = costs.reduce((sum, c) => sum + (c.total ?? c.amount), 0); + + return ( +
+
+
Event costs
+ {!isClosed && editingId === null && ( + + )} +
+ + {isClosed &&
This event is closed — costs can't be changed until it's reopened.
} + + + + + + + + + + + {!isClosed && } + + + + {costs.map(c => ( + + + + + + + + {!isClosed && ( + + )} + + ))} + {costs.length === 0 && ( + + )} + + {costs.length > 0 && ( + + + + + {!isClosed && + + )} +
LabelTypeTicket typePaid fromAmountTotal
{c.label}{c.costType === "once_off" ? "Once-off" : "Per item"}{c.eventOption?.name || "—"}{c.paidFromMethod || "—"}{money(c.amount)}{money(c.total ?? c.amount)} + + +
No costs added yet.
Total costs{money(totalCosts)}} +
+ + {editingId !== null && ( +
+ {err &&
{err}
} +
+
+ + setLabel(e.target.value)} placeholder="e.g. Venue hire" /> +
+
+ + +
+ {costType === "per_item" && ( +
+ + +
+ )} +
+ + setAmount(e.target.value)} /> +
+
+ + +

If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method's expected amount.

+
+
+ + setNotes(e.target.value)} /> +
+
+
+ + +
+
+ )} +
+ ); +} + +// ─── Reconciliation tab ───────────────────────────────────────────────────── + +function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged }: { + eventId: string; token: string; data: EventFinancials; busy: boolean; + setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; +}) { + const isClosed = data.event.cashupStatus === "closed"; + const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined; + + const initialLines: Record = useMemo(() => { + const base: Record = { cash: { actualAmount: "", notes: "" }, card: { actualAmount: "", notes: "" }, eft: { actualAmount: "", notes: "" }, other: { actualAmount: "", notes: "" } }; + for (const l of draftLines || []) { + if (l.method in base) base[l.method as CashupMethod] = { actualAmount: l.actualAmount != null ? String(l.actualAmount) : "", notes: l.notes || "" }; + } + return base; + }, [draftLines]); + + const initialDenomCounts: Record = useMemo(() => { + const cashLine = (draftLines || []).find(l => l.method === "cash"); + const out: Record = {}; + for (const d of cashLine?.denominations || []) out[d.value] = String(d.count); + return out; + }, [draftLines]); + + const [lines, setLines] = useState>(initialLines); + const [denomCounts, setDenomCounts] = useState>(initialDenomCounts); + const [closeNotes, setCloseNotes] = useState(""); + const [reopenNotes, setReopenNotes] = useState(""); + + useEffect(() => { setLines(initialLines); setDenomCounts(initialDenomCounts); }, [initialLines, initialDenomCounts]); + + const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => { + setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } })); + }; + + const cashDenominationsPayload = () => ZAR_DENOMINATIONS + .map(value => ({ value, count: parseInt(denomCounts[value] || "0", 10) || 0 })) + .filter(d => d.count > 0); + + const cashActualFromDenoms = cashDenominationsPayload().reduce((sum, d) => sum + d.value * d.count, 0); + + const buildLinesPayload = () => METHODS.map(m => m === "cash" + ? { method: "cash", denominations: cashDenominationsPayload(), notes: lines.cash.notes || null } + : { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null }); + + const saveDraft = async () => { + setBusy(true); setError(null); + try { + await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } }); + onChanged(); + } catch (e: any) { + setError(e?.message || "Failed to save draft"); + } finally { + setBusy(false); + } + }; + + const closeWithCashup = async () => { + if (!confirm("Close this event with the entered reconciliation? This will count unallocated donations as profit and fully lock the event until it's reopened.")) return; + setBusy(true); setError(null); + try { + await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } }); + onChanged(); + } catch (e: any) { + setError(e?.message || "Failed to close event"); + } finally { + setBusy(false); + } + }; + + const quickClose = async () => { + if (!confirm("Quick close this event without a per-method cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return; + setBusy(true); setError(null); + try { + await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } }); + onChanged(); + } catch (e: any) { + setError(e?.message || "Failed to close event"); + } finally { + setBusy(false); + } + }; + + const reopen = async () => { + if (!confirm("Reopen this event? Registrations, payments, refunds and donations will be allowed again.")) return; + setBusy(true); setError(null); + try { + await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } }); + onChanged(); + } catch (e: any) { + setError(e?.message || "Failed to reopen event"); + } finally { + setBusy(false); + } + }; + + const reconciled = data.reconciled; + + return ( +
+
+
Cash-up reconciliation
+ + + + + + + + + + + + + + {METHODS.map(m => { + const r = reconciled?.byMethod?.[m]; + return ( + + + + + + + + + + ); + })} + +
MethodIncomeCosts from methodExpected cashActualVarianceNotes
{METHOD_LABELS[m]}{money(data.paymentsByMethod[m])}{money(data.costsByMethod[m])}{money(data.expectedCashByMethod[m])} + {isClosed ? ( + money(r?.actual ?? null) + ) : m === "cash" ? ( + money(cashActualFromDenoms) + ) : ( + setLine(m, "actualAmount", e.target.value)} /> + )} + + {isClosed + ? (r?.variance != null ? money(r.variance) : not reconciled) + : (m === "cash" + ? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "—") + : (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))} + + {isClosed ? (r?.notes || "—") : ( + setLine(m, "notes", e.target.value)} /> + )} +
+
+ +
+
Cash denomination count
+ {isClosed ? ( + reconciled?.byMethod?.cash?.denominations?.length ? ( + + + {reconciled.byMethod.cash.denominations.map(d => ( + + ))} + +
{denomLabel(d.value)}× {d.count}{money(d.value * d.count)}
+ ) :
No denomination breakdown recorded for this cashup.
+ ) : ( +
+ {ZAR_DENOMINATIONS.map(v => ( +
+ {denomLabel(v)} + × + setDenomCounts(prev => ({ ...prev, [v]: e.target.value }))} + /> +
+ ))} +
+ )} +
+ +
+
+
Donations counted as profit
+
{money(data.unallocatedDonationsTotal)}
+
+
+
Total costs
+
{money(data.totalCosts)}
+
+
+
Net profit
+
{money(data.netProfit)}
+
+
+ + {!isClosed && ( +
+ + setCloseNotes(e.target.value)} /> +
+ + + +
+
+ )} + + {isClosed && ( +
+ + setReopenNotes(e.target.value)} /> +
+ +
+
+ )} + +
+
History
+ {data.history.length === 0 &&
No close/reopen actions yet.
} +
    + {data.history.map(h => ( +
  • + + {h.action === "closed" ? "Closed (full cashup)" : h.action === "quick_closed" ? "Quick closed" : "Reopened"} + {" — "}{h.performedBy?.name || "Unknown"} — {new Date(h.createdAt).toLocaleString()} + + {(h.action === "closed" || h.action === "quick_closed") && ( + Donations to profit {money(h.unallocatedDonationsTotal)} · Costs {money(h.totalCosts)} + )} +
  • + ))} +
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/admin/cashup/page.tsx b/frontend/src/app/dashboard/admin/cashup/page.tsx new file mode 100644 index 0000000..48c6cf8 --- /dev/null +++ b/frontend/src/app/dashboard/admin/cashup/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/hooks/useAuth"; +import { apiFetch } from "@/lib/api"; + +export default function CashupLandingPage() { + const { token } = useAuth(); + const router = useRouter(); + + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [showPast, setShowPast] = useState(true); + const [showInactive, setShowInactive] = useState(false); + const [showClosed, setShowClosed] = useState(false); + + useEffect(() => { + if (!token) return; + (async () => { + setLoading(true); + setError(null); + try { + const evs = await apiFetch("/api/events/all?includePast=true&includeInactive=true", { authToken: token }); + setEvents(Array.isArray(evs) ? evs : []); + } catch (e: any) { + setError(e?.message || "Failed to load events"); + } finally { + setLoading(false); + } + })(); + }, [token]); + + const filtered = useMemo(() => { + const now = new Date(); + return events + .filter(ev => showPast || !ev.endDate || new Date(ev.endDate) >= now) + .filter(ev => showInactive || ev.isActive !== false) + .filter(ev => showClosed || ev.cashupStatus !== "closed") + .filter(ev => !search.trim() || ev.title?.toLowerCase().includes(search.trim().toLowerCase())) + .sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()); + }, [events, showPast, showInactive, showClosed, search]); + + return ( +
+
+
+

Post-event Cashup

+

Set costs, reconcile takings, and close out an event. Admin only.

+
+ +
+ + {error &&
{error}
} + +
+ setSearch(e.target.value)} + /> + + + +
+ + {loading &&
Loading…
} + + {!loading && ( +
    + {filtered.map(ev => { + const isClosed = ev.cashupStatus === "closed"; + return ( +
  • router.push(`/dashboard/admin/cashup/${ev.id}`)} + > +
    +
    + {ev.title} + + {isClosed ? "Closed" : "Open"} + +
    +
    + {ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""} +
    +
    + Manage → +
  • + ); + })} + {filtered.length === 0 &&
    No events match the current filters.
    } +
+ )} +
+ ); +} diff --git a/frontend/src/app/dashboard/admin/forms/page.tsx b/frontend/src/app/dashboard/admin/forms/page.tsx new file mode 100644 index 0000000..70459aa --- /dev/null +++ b/frontend/src/app/dashboard/admin/forms/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import React from "react"; +import FormsBrowserPage from "@/app/dashboard/supervisor/forms/page"; + +export default function AdminFormsBrowserPage() { + // Reuse the same component; admin also has access + return ; +} diff --git a/frontend/src/app/dashboard/admin/page.tsx b/frontend/src/app/dashboard/admin/page.tsx new file mode 100644 index 0000000..4b130a5 --- /dev/null +++ b/frontend/src/app/dashboard/admin/page.tsx @@ -0,0 +1,240 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { apiFetch } from "@/lib/api"; +import { useStableState } from "@/hooks/useStableState"; +import { useVisiblePolling } from "@/hooks/useVisiblePolling"; + +export default function AdminDashboardPage() { + const { user, loading, token } = useAuth(); + const router = useRouter(); + + const isAdmin = useMemo(() => (user?.role === "admin"), [user]); + + useEffect(() => { + if (loading) return; + if (!user) router.replace("/login"); + }, [user, loading, router]); + + // Everything this dashboard displays comes from one endpoint (/api/stats/admin) that + // computes it all server-side — no more separate calls plus a full payments/events pull + // just to reduce them down to a couple of numbers client-side. + // useStableState skips the re-render entirely when a poll returns identical data, and + // hasLoadedOnce below means "Refreshing…" only ever shows for the very first load — + // together these stop the stats panels from flickering on every 15s poll. + const [paymentStats, setPaymentStats] = useStableState(null); + const [scanStats, setScanStats] = useStableState(null); + const [activeEventsCount, setActiveEventsCount] = useStableState(0); + const [recentScans, setRecentScans] = useStableState([]); + const [loadingStats, setLoadingStats] = useState(false); + const hasLoadedOnce = useRef(false); + + const loadStats = async () => { + if (!token) return; + const isFirstLoad = !hasLoadedOnce.current; + try { + if (isFirstLoad) setLoadingStats(true); + const data = await apiFetch("/api/stats/admin", { authToken: token }); + setScanStats(data.scanStats); + setPaymentStats(data.paymentStats); + setActiveEventsCount(data.activeEventsCount || 0); + setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []); + } catch (e) { + // ignore errors for dashboard summaries + } finally { + hasLoadedOnce.current = true; + if (isFirstLoad) setLoadingStats(false); + } + }; + + useEffect(() => { + if (!token) return; + loadStats(); + }, [token]); + + // Poll every 15s while the tab is visible; pause in the background and refetch + // immediately on return instead of leaving stale numbers up. + useVisiblePolling(() => { + if (!token) return; + loadStats(); + }, 15000, !!token); + + return ( +
+
+

Admin Dashboard{user ? ` — ${user.name}` : ""}

+
+ + + + + + +
+
+ + {!isAdmin && ( +
+ You need admin access to use these tools. +
+ )} + +
+
+
+
Quick actions
+
+ + + + + + + + + + + + + + + +
+
+ +
+
+

Recent scans

+ {loadingStats && Refreshing…} +
+
    + {recentScans.map((u: any) => ( +
  • +
    +
    {u.ticket?.event?.title || u.ticket?.eventId || 'Event'}
    +
    {new Date(u.scannedAt).toLocaleString()}
    +
    +
    {u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}
    +
    Scanned by: {u.scannedBy?.name || u.scannedById}
    +
  • + ))} + {recentScans.length === 0 &&
  • No scans yet.
  • } +
+
+ +
+
+

Payments

+ {loadingStats && Refreshing…} +
+ {paymentStats ? ( +
+
+
Today
+
R{paymentStats.totalToday}
+
+
+
Past Week
+
R{paymentStats.totalWeek}
+
+
+
Past Month
+
R{paymentStats.totalMonth}
+
+
+ ) : ( +
No payment data yet.
+ )} + + {scanStats?.byStaff?.length > 0 && ( +
+
Today by staff
+
    + {scanStats.byStaff.map((s: any) => ( +
  • + {s.name || 'Staff'} + {s.count} +
  • + ))} +
+
+ )} +
+
+ +
+
+

Admin stats

+ {loadingStats &&
Loading…
} +
+
+
+
Active events
+
{activeEventsCount}
+
+ +
+
+
Revenue today
+
R {(paymentStats?.totalToday || 0).toFixed(2)}
+
+
+
Donations today
+
{paymentStats?.donationsToday || 0}
+
+
+
+ +
+

As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.

+
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/admin/registrations/page.tsx b/frontend/src/app/dashboard/admin/registrations/page.tsx new file mode 100644 index 0000000..7ec0d88 --- /dev/null +++ b/frontend/src/app/dashboard/admin/registrations/page.tsx @@ -0,0 +1,350 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { apiFetch } from "@/lib/api"; + +const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const; + +function fuzzyMatch(query: string, target: string): boolean { + const q = query.toLowerCase(); + const t = target.toLowerCase(); + if (t.includes(q)) return true; + const tokens = q.split(/\s+/).filter(Boolean); + return tokens.every(tok => t.includes(tok)); +} + +export default function AdminRegistrationsPage() { + const { user, loading, token } = useAuth(); + const router = useRouter(); + + const isAdmin = useMemo(() => user?.role === "admin", [user]); + + useEffect(() => { + if (loading) return; + if (!user) router.replace("/login"); + }, [user, loading, router]); + + const [registrations, setRegistrations] = useState([]); + const [events, setEvents] = useState([]); + const [loadingRegs, setLoadingRegs] = useState(false); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + + // Filters + const [query, setQuery] = useState(""); + const [eventFilter, setEventFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [includePastEvents, setIncludePastEvents] = useState(false); + const [includeInactiveEvents, setIncludeInactiveEvents] = useState(false); + + // Expanded rows + form responses cache + const [expanded, setExpanded] = useState>(new Set()); + const [formResponses, setFormResponses] = useState>({}); + const [loadingForms, setLoadingForms] = useState>(new Set()); + + const loadRegistrations = async () => { + if (!token) return; + try { + setLoadingRegs(true); + const regs = await apiFetch("/api/registrations", { authToken: token }); + const list = Array.isArray(regs) ? regs.sort((a, b) => { + const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId); + if (eventCompare !== 0) return eventCompare; + return (a.user?.name || a.userId).localeCompare(b.user?.name || b.userId); + }) : []; + setRegistrations(list); + } catch (e: any) { + setError(e?.message || "Failed to load registrations"); + } finally { + setLoadingRegs(false); + } + }; + + const loadEvents = async () => { + if (!token) return; + try { + const params = new URLSearchParams(); + if (includePastEvents) params.set("includePast", "true"); + if (includeInactiveEvents) params.set("includeInactive", "true"); + const qs = params.toString() ? `?${params.toString()}` : ""; + const evs = await apiFetch(`/api/events/all${qs}`, { authToken: token }); + setEvents(Array.isArray(evs) ? evs.sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []); + } catch {} + }; + + useEffect(() => { + loadRegistrations(); + }, [token]); + + useEffect(() => { + loadEvents(); + }, [token, includePastEvents, includeInactiveEvents]); + + const toggleExpand = async (reg: any) => { + const id = reg.id; + const next = new Set(expanded); + if (next.has(id)) { + next.delete(id); + setExpanded(next); + return; + } + next.add(id); + setExpanded(next); + // Load form responses if not cached + if (!formResponses[id] && !loadingForms.has(id)) { + setLoadingForms(prev => new Set(prev).add(id)); + try { + const res = await apiFetch(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! }); + const items = Array.isArray(res?.items) ? res.items : (Array.isArray(res) ? res : []); + setFormResponses(prev => ({ ...prev, [id]: items })); + } catch { + setFormResponses(prev => ({ ...prev, [id]: [] })); + } finally { + setLoadingForms(prev => { const s = new Set(prev); s.delete(id); return s; }); + } + } + }; + + const cancelRegistration = async (reg: any) => { + if (!token) return; + setError(null); setInfo(null); + if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return; + try { + await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token }); + setInfo("Registration cancelled"); + await loadRegistrations(); + } catch (e: any) { + setError(e?.message || "Failed to cancel registration"); + } + }; + + const updateStatus = async (reg: any, status: string) => { + if (!token) return; + setError(null); setInfo(null); + try { + await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } }); + setInfo("Status updated"); + await loadRegistrations(); + } catch (e: any) { + setError(e?.message || "Failed to update status"); + } + }; + + const eventIds = useMemo(() => new Set(events.map((ev: any) => ev.id)), [events]); + + const filtered = useMemo(() => { + return registrations.filter((r: any) => { + if (!eventIds.has(r.eventId)) return false; + if (eventFilter && r.eventId !== eventFilter) return false; + if (statusFilter && r.status !== statusFilter) return false; + if (!query.trim()) return true; + const haystack = [ + r.id, r.user?.name, r.user?.email, r.user?.phoneNumber, + r.userId, r.event?.title, r.eventId, r.status, + ].map((x: any) => String(x || "")).join(" "); + return fuzzyMatch(query.trim(), haystack); + }); + }, [registrations, query, eventFilter, statusFilter]); + + const statusColor = (s: string) => { + if (s === "paid") return "text-green-700 bg-green-50"; + if (s === "confirmed") return "text-blue-700 bg-blue-50"; + if (s === "partial_paid") return "text-amber-700 bg-amber-50"; + if (s === "cancelled") return "text-red-700 bg-red-50"; + return "text-gray-700 bg-gray-50"; + }; + + return ( +
+
+

Manage Registrations

+ +
+ + {!isAdmin && ( +
+ You need admin access to use this page. +
+ )} + + {error &&
{error}
} + {info &&
{info}
} + + {/* Filters */} +
+
+
+ + setQuery(e.target.value)} + /> +
+
+ + +
+ + +
+
+
+ + +
+ +
+
{filtered.length} of {registrations.length} registrations
+
+ +
+
    + {filtered.map((r: any) => { + const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => { + const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) + ? Number(opt.priceSnapshot) + : (opt.eventOption?.price || 0); + return sum + unit * (opt.quantity || 0); + }, 0); + const isExpanded = expanded.has(r.id); + const responses = formResponses[r.id]; + const loadingResponse = loadingForms.has(r.id); + + return ( +
  • +
    toggleExpand(r)}> +
    +
    +
    + {r.user?.name || r.userId} + + {r.event?.title || r.eventId} + {r.status} +
    +
    + {r.user?.email && {r.user.email}} + {r.user?.phoneNumber && {r.user.phoneNumber}} + R {totalDue.toFixed(2)} + #{String(r.id).slice(0, 8)} +
    +
    +
    + {new Date(r.createdAt).toLocaleDateString()} + {isExpanded ? "▲" : "▼"} +
    +
    +
    + + {isExpanded && ( +
    e.stopPropagation()}> + {/* Actions */} +
    + + +
    + + {/* Ticket options */} + {(r.registrationOptions || []).length > 0 && ( +
    +
    Ticket options
    +
    + {r.registrationOptions.map((opt: any) => ( +
    +
    + {opt.eventOption?.name || opt.eventOptionId} + {opt.variant?.name && ({opt.variant.name})} +
    +
    + {(() => { + const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) + ? Number(opt.priceSnapshot) + : (opt.variant?.price ?? opt.eventOption?.price ?? 0); + return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`; + })()} +
    + {opt.appliedTierId && ( +
    Early-bird price applied
    + )} +
    + ))} +
    +
    Total: R {totalDue.toFixed(2)}
    +
    + )} + + {/* Form responses */} +
    +
    Form responses
    + {loadingResponse ? ( +
    Loading…
    + ) : !responses || responses.length === 0 ? ( +
    No form responses submitted.
    + ) : ( +
    + {responses.map((resp: any, idx: number) => ( +
    +
    Response #{idx + 1}
    +
    + {(resp.answers || []).map((a: any) => ( +
    +
    {a.field?.label || a.fieldId}
    +
    {a.value}
    +
    + ))} +
    +
    + ))} +
    + )} +
    + + {/* Metadata */} +
    + Registration ID: {r.id} · Created: {new Date(r.createdAt).toLocaleString()} +
    +
    + )} +
  • + ); + })} + {filtered.length === 0 && ( +
  • {loadingRegs ? "Loading…" : "No registrations found."}
  • + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/dashboard/admin/settings/page.tsx b/frontend/src/app/dashboard/admin/settings/page.tsx new file mode 100644 index 0000000..b2aa25e --- /dev/null +++ b/frontend/src/app/dashboard/admin/settings/page.tsx @@ -0,0 +1,511 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api"; +import { useSiteSettings } from "@/contexts/SiteSettingsContext"; + +type TabId = "organisation" | "branding" | "notifications" | "email" | "legal"; + +const TABS: { id: TabId; label: string }[] = [ + { id: "organisation", label: "Organisation" }, + { id: "branding", label: "Branding" }, + { id: "notifications", label: "Notifications" }, + { id: "email", label: "Email" }, + { id: "legal", label: "Legal" }, +]; + +const inputCls = + "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"; + +function Field({ + label, hint, required, children, +}: { + label: string; hint?: string; required?: boolean; children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +function SaveBar({ + saving, onSave, result, onDismiss, +}: { + saving: boolean; + onSave: () => void; + result: { ok: boolean; message: string } | null; + onDismiss: () => void; +}) { + return ( +
+ {result ? ( + + {result.ok ? "✓" : "✗"} {result.message} + + + ) : ( + + )} + +
+ ); +} + +export default function SiteSettingsPage() { + const { token } = useAuth(); + const router = useRouter(); + const { reload: reloadSettings } = useSiteSettings(); + + const [activeTab, setActiveTab] = useState("organisation"); + const [loadingInitial, setLoadingInitial] = useState(true); + + // Per-tab save state + const [saving, setSaving] = useState(false); + const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); + + // ── Organisation ── + const [orgName, setOrgName] = useState(""); + const [orgTagline, setOrgTagline] = useState(""); + const [orgEmail, setOrgEmail] = useState(""); + const [orgPhone, setOrgPhone] = useState(""); + const [orgAddress, setOrgAddress] = useState(""); + const [appBaseUrl, setAppBaseUrl] = useState(""); + + // ── Branding ── + const [accentColor, setAccentColor] = useState("#2563eb"); + const [logoUrl, setLogoUrl] = useState(""); + const [logoFile, setLogoFile] = useState(null); + const [logoPreview, setLogoPreview] = useState(null); + const fileRef = useRef(null); + + // ── Notifications ── + const [notifEmails, setNotifEmails] = useState(""); + + // ── SMTP ── + const [smtpHost, setSmtpHost] = useState(""); + const [smtpPort, setSmtpPort] = useState("587"); + const [smtpSecure, setSmtpSecure] = useState(false); + const [smtpFrom, setSmtpFrom] = useState(""); + const [smtpUser, setSmtpUser] = useState(""); + const [smtpPass, setSmtpPass] = useState(""); + const [smtpPassSet, setSmtpPassSet] = useState(false); + const [smtpTesting, setSmtpTesting] = useState(false); + const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; message: string; raw?: string } | null>(null); + + // ── Legal ── + const [legalOperatorName, setLegalOperatorName] = useState(""); + const [legalIoName, setLegalIoName] = useState(""); + const [legalIoEmail, setLegalIoEmail] = useState(""); + const [legalWebsiteUrl, setLegalWebsiteUrl] = useState(""); + const [legalEffectiveDate, setLegalEffectiveDate] = useState(""); + + // ── Load all settings once ──────────────────────────────────────────────── + useEffect(() => { + if (!token) return; + apiFetch>("/api/settings/all", { authToken: token }) + .then((s) => { + setOrgName(s.org_name || ""); + setOrgTagline(s.org_tagline || ""); + setOrgEmail(s.org_email || ""); + setOrgPhone(s.org_phone || ""); + setOrgAddress(s.org_address || ""); + setAppBaseUrl(s.app_base_url || ""); + setAccentColor(s.accent_color || "#2563eb"); + setLogoUrl(s.logo_url || ""); + setNotifEmails(s.reg_notification_emails || ""); + setSmtpHost(s.smtp_host || ""); + setSmtpPort(s.smtp_port || "587"); + setSmtpSecure((s.smtp_secure || "").toLowerCase() === "true"); + setSmtpFrom(s.smtp_from || ""); + setSmtpUser(s.smtp_user || ""); + setSmtpPassSet(!!s.smtp_pass && s.smtp_pass !== ""); + setSmtpPass(""); + setLegalOperatorName(s.legal_operator_name || ""); + setLegalIoName(s.legal_io_name || ""); + setLegalIoEmail(s.legal_io_email || ""); + setLegalWebsiteUrl(s.legal_website_url || ""); + setLegalEffectiveDate(s.legal_effective_date || ""); + }) + .catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" })) + .finally(() => setLoadingInitial(false)); + }, [token]); + + // Clear result when switching tabs + const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); }; + + // ── Save helpers ────────────────────────────────────────────────────────── + const save = async (updates: Record) => { + if (!token) return; + setSaving(true); + setResult(null); + try { + await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates }); + setResult({ ok: true, message: "Saved." }); + reloadSettings(); + } catch (e: any) { + setResult({ ok: false, message: e?.message || "Failed to save" }); + } finally { + setSaving(false); + } + }; + + const saveOrganisation = () => save({ + org_name: orgName.trim(), + org_tagline: orgTagline.trim(), + org_email: orgEmail.trim(), + org_phone: orgPhone.trim(), + org_address: orgAddress.trim(), + app_base_url: appBaseUrl.trim(), + }); + + const saveBranding = async () => { + if (!token) return; + setSaving(true); + setResult(null); + try { + let finalLogoUrl = logoUrl; + if (logoFile) { + const fd = new FormData(); + fd.append("image", logoFile); + const res = await fetch(`${API_BASE}/api/uploads/logo`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }); + if (!res.ok) throw new Error("Logo upload failed"); + const data = await res.json(); + finalLogoUrl = data.url || logoUrl; + } + await apiFetch("/api/settings", { + method: "PUT", authToken: token, + body: { accent_color: accentColor, logo_url: finalLogoUrl }, + }); + setLogoUrl(finalLogoUrl); + setLogoFile(null); + setLogoPreview(null); + setResult({ ok: true, message: "Saved." }); + reloadSettings(); + } catch (e: any) { + setResult({ ok: false, message: e?.message || "Failed to save" }); + } finally { + setSaving(false); + } + }; + + const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() }); + + const saveSmtp = async () => { + if (!token) return; + setSaving(true); + setResult(null); + try { + const updates: Record = { + smtp_host: smtpHost.trim(), + smtp_port: smtpPort.trim(), + smtp_secure: smtpSecure ? "true" : "false", + smtp_from: smtpFrom.trim(), + smtp_user: smtpUser.trim(), + }; + if (smtpPass.trim() !== "") updates.smtp_pass = smtpPass.trim(); + await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates }); + if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); } + setResult({ ok: true, message: "Saved." }); + reloadSettings(); + } catch (e: any) { + setResult({ ok: false, message: e?.message || "Failed to save" }); + } finally { + setSaving(false); + } + }; + + const saveLegal = () => save({ + legal_operator_name: legalOperatorName.trim(), + legal_io_name: legalIoName.trim(), + legal_io_email: legalIoEmail.trim(), + legal_website_url: legalWebsiteUrl.trim(), + legal_effective_date: legalEffectiveDate.trim(), + }); + + const handleTestSmtp = async () => { + if (!token) return; + setSmtpTesting(true); + setSmtpTestResult(null); + try { + await apiFetch("/api/settings/test-smtp", { + method: "POST", + authToken: token, + body: { + host: smtpHost.trim(), + port: smtpPort.trim(), + secure: smtpSecure, + from: smtpFrom.trim(), + user: smtpUser.trim(), + pass: smtpPass.trim() !== "" ? smtpPass.trim() : "••••••••", + }, + }); + setSmtpTestResult({ ok: true, message: "Connection successful — check your inbox for a test email." }); + } catch (e: any) { + setSmtpTestResult({ ok: false, message: e?.message || "SMTP test failed", raw: e?.data?.raw }); + } finally { + setSmtpTesting(false); + } + }; + + if (loadingInitial) return
Loading settings…
; + + const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null); + + return ( +
+ {/* Header */} +
+
+

Site Settings

+

Configure your organisation, branding, email, and legal pages.

+
+ +
+ + {/* Tab bar */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* ── Organisation ─────────────────────────────────────────────────── */} + {activeTab === "organisation" && ( +
+ + setOrgName(e.target.value)} /> + + + setOrgTagline(e.target.value)} /> + +
+ + setOrgEmail(e.target.value)} /> + + + setOrgPhone(e.target.value)} /> + +
+ + setOrgAddress(e.target.value)} /> + + + setAppBaseUrl(e.target.value)} /> + + setResult(null)} /> +
+ )} + + {/* ── Branding ─────────────────────────────────────────────────────── */} + {activeTab === "branding" && ( +
+ +
+ setAccentColor(e.target.value)} /> + setAccentColor(e.target.value)} /> +
+
+ + + + {currentLogoSrc && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Current logo + +
+ )} + { + const file = e.target.files?.[0]; + if (!file) return; + setLogoFile(file); + setLogoPreview(URL.createObjectURL(file)); + }} className="text-sm" /> +
+ + setResult(null)} /> +
+ )} + + {/* ── Notifications ─────────────────────────────────────────────────── */} + {activeTab === "notifications" && ( +
+ + setNotifEmails(e.target.value)} /> + + setResult(null)} /> +
+ )} + + {/* ── Email / SMTP ──────────────────────────────────────────────────── */} + {activeTab === "email" && ( +
+

+ Outgoing email for tickets, payment confirmations, and account notifications. + Leave blank to use server environment variables. The password is stored encrypted. +

+ +
+ + setSmtpHost(e.target.value)} /> + + + setSmtpPort(e.target.value)} /> + +
+ +
+ setSmtpSecure(e.target.checked)} className="rounded" /> + +
+ + + setSmtpFrom(e.target.value)} /> + + +
+ + setSmtpUser(e.target.value)} autoComplete="username" /> + + +
+ setSmtpPass(e.target.value)} autoComplete="new-password" /> + {smtpPassSet && smtpPass === "" && ( + ● saved + )} +
+
+
+ + {/* Test connection */} +
+
+ + {smtpTestResult && ( + + {smtpTestResult.ok ? "✓" : "✗"} {smtpTestResult.message} + + )} +
+ {smtpTestResult && !smtpTestResult.ok && smtpTestResult.raw && ( +
+ Show technical details +
{smtpTestResult.raw}
+
+ )} +
+ + setResult(null)} /> +
+ )} + + {/* ── Legal ─────────────────────────────────────────────────────────── */} + {activeTab === "legal" && ( +
+

+ These values populate the Terms of Use and Privacy Policy pages automatically. +

+ + + setLegalOperatorName(e.target.value)} /> + + + setLegalWebsiteUrl(e.target.value)} /> + + + setLegalEffectiveDate(e.target.value)} /> + + +
+

Information Officer (POPIA)

+
+ + setLegalIoName(e.target.value)} /> + + + setLegalIoEmail(e.target.value)} /> + +
+
+ + setResult(null)} /> +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/dashboard/admin/users/page.tsx b/frontend/src/app/dashboard/admin/users/page.tsx new file mode 100644 index 0000000..80380e6 --- /dev/null +++ b/frontend/src/app/dashboard/admin/users/page.tsx @@ -0,0 +1,435 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { apiFetch } from "@/lib/api"; + +interface UserItem { + id: string; + name: string; + email: string; + role: string; + phoneNumber?: string | null; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +const roleOptions = ["user", "staff", "supervisor", "admin"] as const; +type Role = typeof roleOptions[number]; + +// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order +function fuzzyMatch(query: string, target: string): boolean { + const q = query.toLowerCase(); + const t = target.toLowerCase(); + if (t.includes(q)) return true; + // token-based: all tokens must appear somewhere + const tokens = q.split(/\s+/).filter(Boolean); + return tokens.every(tok => t.includes(tok)); +} + +export default function AdminUsersPage() { + const { user, loading, token } = useAuth(); + const router = useRouter(); + + const isAdmin = useMemo(() => user?.role === "admin", [user]); + + useEffect(() => { + if (loading) return; + if (!user) router.replace("/login"); + }, [user, loading, router]); + + // Data state + const [users, setUsers] = useState([]); + const [fetching, setFetching] = useState(false); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + const [pageSize, setPageSize] = useState(50); + + // Search and filter state (server-side) + const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [roleFilter, setRoleFilter] = useState(""); + const [activeFilter, setActiveFilter] = useState<"" | "true" | "false">(""); + + // Debounce search + useEffect(() => { + const t = setTimeout(() => setDebouncedQuery(query), 350); + return () => clearTimeout(t); + }, [query]); + + // Create form state + const [createOpen, setCreateOpen] = useState(false); + const [cName, setCName] = useState(""); + const [cEmail, setCEmail] = useState(""); + const [cPassword, setCPassword] = useState(""); + const [cPhone, setCPhone] = useState(""); + const [cRole, setCRole] = useState("user"); + const [creating, setCreating] = useState(false); + + // Inline edit state + const [editingId, setEditingId] = useState(null); + const [editData, setEditData] = useState & { password?: string }>({}); + const [saving, setSaving] = useState(false); + + const buildQuery = useCallback((p: number, ps = pageSize) => { + const qs = new URLSearchParams({ page: String(p), limit: String(ps) }); + if (debouncedQuery.trim()) qs.set("search", debouncedQuery.trim()); + if (roleFilter) qs.set("role", roleFilter); + if (activeFilter !== "") qs.set("isActive", activeFilter); + return `/api/users?${qs.toString()}`; + }, [debouncedQuery, roleFilter, activeFilter, pageSize]); + + const loadUsers = useCallback(async (p = 1, ps = pageSize) => { + if (!token) return; + setError(null); + setFetching(true); + try { + const url = buildQuery(p, ps); + const res = await apiFetch(url, { authToken: token }); + setUsers(Array.isArray(res?.data) ? res.data : []); + setTotal(res?.total ?? 0); + setTotalPages(res?.pages ?? 1); + setPage(p); + } catch (e: any) { + setError(e?.message || "Failed to load users"); + } finally { + setFetching(false); + } + }, [token, buildQuery]); + + // Initial load + reload on filter change + useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]); + + const goToPage = (p: number) => loadUsers(p); + + const handlePageSizeChange = (newSize: number) => { + setPageSize(newSize); + loadUsers(1, newSize); + }; + + const resetCreateForm = () => { + setCName(""); setCEmail(""); setCPassword(""); setCPhone(""); setCRole("user"); + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!cEmail || !cPassword) { setError("Email and password are required"); return; } + try { + setCreating(true); + const created = await apiFetch("/api/users", { + method: "POST", + body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined }, + }); + if (cRole && cRole !== "user" && created?.id) { + await apiFetch(`/api/users/${encodeURIComponent(created.id)}`, { + method: "PUT", authToken: token!, body: { role: cRole }, + }); + } + resetCreateForm(); + setCreateOpen(false); + await loadUsers(page); + } catch (e: any) { + setError(e?.message || "Failed to create user"); + } finally { + setCreating(false); + } + }; + + const startEdit = (u: UserItem) => { setEditingId(u.id); setEditData({ ...u, password: "" }); }; + const cancelEdit = () => { setEditingId(null); setEditData({}); }; + + const saveEdit = async () => { + if (!editingId) return; + try { + setSaving(true); + const payload: any = { + name: editData.name, + email: editData.email, + role: editData.role, + phoneNumber: editData.phoneNumber || null, + isActive: editData.isActive, + }; + if (editData.password && editData.password.trim().length > 0) { + payload.password = editData.password.trim(); + } + await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, { + method: "PUT", authToken: token!, body: payload, + }); + setEditingId(null); + setEditData({}); + await loadUsers(page); + } catch (e: any) { + setError(e?.message || "Failed to save user"); + } finally { + setSaving(false); + } + }; + + const revokeUserSessions = async (id: string, name: string) => { + if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return; + try { + await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! }); + } catch (e: any) { + setError(e?.message || "Failed to revoke sessions"); + } + }; + + const deactivate = async (id: string) => { + if (!confirm("Deactivate this user? Their account will be disabled.")) return; + try { + await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! }); + await loadUsers(page); + } catch (e: any) { + setError(e?.message || "Failed to deactivate user"); + } + }; + + const deleteUserData = async (id: string, name: string) => { + if (!confirm(`Delete personal data for "${name}"?\n\nThis will set their name to "Deleted User", clear their email and phone number, and deactivate the account. This action cannot be undone.`)) return; + try { + await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! }); + await loadUsers(page); + } catch (e: any) { + setError(e?.message || "Failed to delete user data"); + } + }; + + return ( +
+
+

User Management

+
+ + +
+
+ + {!isAdmin && ( +
+ You need admin access to manage users. +
+ )} + + {error &&
{error}
} + + {createOpen && ( +
+
+ + setCName(e.target.value)} /> +
+
+ + setCEmail(e.target.value)} required /> +
+
+ + setCPassword(e.target.value)} required /> +
+
+ + setCPhone(e.target.value)} /> +
+
+ + +
+
+ +
+
+ )} + +
+ {/* Filters row */} +
+
+ + setQuery(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ {total} user{total !== 1 ? "s" : ""} total + {total > 0 && ` — page ${page} of ${totalPages}`} +
+ +
+ + + + + + + + + + + + + + {users.map(u => ( + + + + + + + + + + ))} + {users.length === 0 && !fetching && ( + + + + )} + {fetching && ( + + + + )} + +
NameEmailRolePhoneActivePasswordActions
+ {editingId === u.id ? ( + setEditData(d => ({ ...d, name: e.target.value }))} /> + ) : ( + {u.name} + )} + + {editingId === u.id ? ( + setEditData(d => ({ ...d, email: e.target.value }))} /> + ) : ( + {u.email} + )} + + {editingId === u.id ? ( + + ) : ( + {u.role} + )} + + {editingId === u.id ? ( + setEditData(d => ({ ...d, phoneNumber: e.target.value }))} /> + ) : ( + {u.phoneNumber || ""} + )} + + {editingId === u.id ? ( + setEditData(d => ({ ...d, isActive: e.target.checked }))} /> + ) : ( + {u.isActive ? "Yes" : "No"} + )} + + {editingId === u.id ? ( + setEditData(d => ({ ...d, password: e.target.value }))} /> + ) : ( + + )} + + {editingId === u.id ? ( +
+ + +
+ ) : ( +
+ + + + +
+ )} +
No users found.
Loading…
+
+ + {totalPages > 1 && ( +
+ Page {page} of {totalPages} +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1) + .reduce<(number | "…")[]>((acc, p, i, arr) => { + if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…"); + acc.push(p); + return acc; + }, []) + .map((p, i) => + p === "…" ? ( + + ) : ( + + ) + )} + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/dashboard/admin/whatsapp/page.tsx b/frontend/src/app/dashboard/admin/whatsapp/page.tsx new file mode 100644 index 0000000..0f00b17 --- /dev/null +++ b/frontend/src/app/dashboard/admin/whatsapp/page.tsx @@ -0,0 +1,830 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { apiFetch } from "@/lib/api"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type WAStatus = + | "WORKING" + | "CONNECTED" + | "SCAN_QR_CODE" + | "STARTING" + | "FAILED" + | "STOPPED" + | string; + +interface ConfigResponse { + tokenMasked: string; + instanceId: string; + hasToken: boolean; + hasInstance: boolean; + configured: boolean; +} + +interface StatusResponse { + status: WAStatus; + message?: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const STATUS_COLORS: Record = { + WORKING: "bg-green-100 text-green-800 border-green-300", + CONNECTED: "bg-green-100 text-green-800 border-green-300", + SCAN_QR_CODE: "bg-yellow-100 text-yellow-800 border-yellow-300", + STARTING: "bg-blue-100 text-blue-800 border-blue-300", + FAILED: "bg-red-100 text-red-800 border-red-300", + STOPPED: "bg-gray-100 text-gray-700 border-gray-300", +}; + +const STATUS_ICONS: Record = { + WORKING: "🟢", + CONNECTED: "🟢", + SCAN_QR_CODE: "📷", + STARTING: "🔄", + FAILED: "🔴", + STOPPED: "⚫", +}; + +const ACTIVE_STATUSES = new Set(["WORKING", "CONNECTED"]); +const POLLING_STATUSES = new Set(["STARTING", "SCAN_QR_CODE", "FAILED", "STOPPED"]); + +function Spinner() { + return ( + + + + + ); +} + +function Alert({ + type, + children, +}: { + type: "ok" | "err" | "info"; + children: React.ReactNode; +}) { + const cls = + type === "ok" + ? "bg-green-50 text-green-800 border-green-200" + : type === "err" + ? "bg-red-50 text-red-800 border-red-200" + : "bg-blue-50 text-blue-800 border-blue-200"; + return ( +
{children}
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function WhatsAppAdminPage() { + const { user, loading, token } = useAuth(); + const router = useRouter(); + const isAdmin = useMemo(() => user?.role === "admin", [user]); + + useEffect(() => { + if (loading) return; + if (!user || !isAdmin) router.replace("/dashboard"); + }, [user, loading, isAdmin, router]); + + // ── Config state (drives wizard steps) ────────────────────────────────────── + const [cfg, setCfg] = useState(null); + const [cfgLoading, setCfgLoading] = useState(true); + + // Derived wizard step: 1 = no token, 2 = token but no instance, 3 = fully configured + const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3; + + // ── Step 1 inputs ──────────────────────────────────────────────────────────── + const [inputToken, setInputToken] = useState(""); + const [savingToken, setSavingToken] = useState(false); + + // ── Step 2 inputs ──────────────────────────────────────────────────────────── + const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create"); + const [inputInstanceId, setInputInstanceId] = useState(""); + const [savingInstance, setSavingInstance] = useState(false); + + // ── Step 3: session state ──────────────────────────────────────────────────── + const [status, setStatus] = useState(null); + const [statusMsg, setStatusMsg] = useState(null); + const [qrSrc, setQrSrc] = useState(null); + const [pairingPhone, setPairingPhone] = useState(""); + + // ── Shared action feedback ─────────────────────────────────────────────────── + const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + const [busy, setBusy] = useState(null); + + // ── Load config ────────────────────────────────────────────────────────────── + const fetchConfig = useCallback(async () => { + if (!token) return; + try { + const res = await apiFetch("/api/whatsapp/config", { authToken: token }); + setCfg(res); + } catch { + // network error — leave cfg null, user sees loading state + } finally { + setCfgLoading(false); + } + }, [token]); + + useEffect(() => { fetchConfig(); }, [fetchConfig]); + + // ── Status fetch (step 3 only) ─────────────────────────────────────────────── + const fetchStatus = useCallback(async () => { + if (!token || step !== 3) return; + try { + const res = await apiFetch("/api/whatsapp/status", { authToken: token }); + setStatus(res.status ?? null); + setStatusMsg(res.message ?? null); + } catch (e: any) { + // Re-fetch config — if the session was not found, backend clears the + // instance ID and the step recomputes to 2 (Session Instance setup). + await fetchConfig(); + setStatus("FAILED"); + setStatusMsg(null); + } + }, [token, step, fetchConfig]); + + useEffect(() => { if (step === 3) fetchStatus(); }, [step, fetchStatus]); + + // Auto-poll status when not stable + useEffect(() => { + if (step !== 3 || status === null) return; + if (ACTIVE_STATUSES.has(status)) return; + const id = setInterval(fetchStatus, 5_000); + return () => clearInterval(id); + }, [step, status, fetchStatus]); + + // ── QR fetch ───────────────────────────────────────────────────────────────── + const fetchQr = useCallback(async () => { + if (!token) return; + try { + const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token }); + if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`); + } catch { + setQrSrc(null); + } + }, [token]); + + useEffect(() => { + if (status === "SCAN_QR_CODE") { fetchQr(); } + else { setQrSrc(null); } + }, [status, fetchQr]); + + // Auto-refresh QR every 20s while waiting + useEffect(() => { + if (status !== "SCAN_QR_CODE") return; + const id = setInterval(fetchQr, 20_000); + return () => clearInterval(id); + }, [status, fetchQr]); + + // ── Generic session action ─────────────────────────────────────────────────── + const doAction = async (action: string, body?: object) => { + if (!token) return; + setBusy(action); + setActionMsg(null); + try { + const res = await apiFetch(`/api/whatsapp/${action}`, { + method: "POST", + authToken: token, + body, + }); + setActionMsg({ type: "ok", text: res?.message || `${action} successful.` }); + await fetchStatus(); + await fetchConfig(); + } catch (e: any) { + let msg = e?.message || `${action} failed.`; + try { msg = JSON.parse(msg)?.message || msg; } catch {} + // SESSION_NOT_FOUND: backend cleared the instance ID — re-fetch config so + // the wizard steps back to Step 2; no need to show an error message. + await fetchConfig(); + if (!msg.includes("SESSION_NOT_FOUND")) { + setActionMsg({ type: "err", text: msg }); + } + await fetchStatus(); + } finally { + setBusy(null); + } + }; + + // ─── Step 1: Save token ────────────────────────────────────────────────────── + const saveToken = async () => { + if (!inputToken.trim()) { + setActionMsg({ type: "err", text: "Please enter your WAWP access token." }); + return; + } + setSavingToken(true); + setActionMsg(null); + try { + await apiFetch("/api/whatsapp/config", { + method: "POST", + authToken: token!, + body: { token: inputToken.trim(), instanceId: "" }, + }); + setInputToken(""); + await fetchConfig(); + } catch (e: any) { + setActionMsg({ type: "err", text: e?.message || "Failed to save token." }); + } finally { + setSavingToken(false); + } + }; + + // ─── Step 2: Enter existing instance ID ────────────────────────────────────── + const saveInstanceId = async () => { + if (!inputInstanceId.trim()) { + setActionMsg({ type: "err", text: "Please enter the Instance ID." }); + return; + } + setSavingInstance(true); + setActionMsg(null); + try { + await apiFetch("/api/whatsapp/config", { + method: "POST", + authToken: token!, + body: { token: "", instanceId: inputInstanceId.trim() }, + // token left blank → backend keeps existing token + }); + setInputInstanceId(""); + await fetchConfig(); + } catch (e: any) { + setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." }); + } finally { + setSavingInstance(false); + } + }; + + // ─── Step 2: Create new instance ───────────────────────────────────────────── + const createInstance = async () => { + setSavingInstance(true); + setActionMsg(null); + try { + const res = await apiFetch("/api/whatsapp/create-instance", { + method: "POST", + authToken: token!, + }); + setActionMsg({ type: "ok", text: res?.message || "Instance created." }); + await fetchConfig(); + } catch (e: any) { + setActionMsg({ type: "err", text: e?.message || "Failed to create instance." }); + } finally { + setSavingInstance(false); + } + }; + + // ─── Pairing code ──────────────────────────────────────────────────────────── + const requestPairingCode = async () => { + if (!pairingPhone.trim()) { + setActionMsg({ type: "err", text: "Enter your phone number first." }); + return; + } + await doAction("request-code", { phoneNumber: pairingPhone.trim() }); + }; + + // ─── Reset credentials (go back to step 1) ─────────────────────────────────── + const resetToken = async () => { + if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return; + try { + await apiFetch("/api/whatsapp/config", { + method: "POST", + authToken: token!, + body: { token: "_clear_", instanceId: "" }, + }); + } catch {} + // Force a re-read — even if the above fails, clear local state + setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null); + }; + + // ──────────────────────────────────────────────────────────────────────────── + // Render + // ──────────────────────────────────────────────────────────────────────────── + + if (loading || cfgLoading) { + return ( +
+ Loading… +
+ ); + } + + return ( +
+ {/* Back button */} + + + {/* Header */} +
+ 💬 +
+

WhatsApp Integration

+

Powered by WAWP

+
+
+ + {/* Step indicator */} + + + {/* Global action message */} + {actionMsg && ( + {actionMsg.text} + )} + + {/* ── STEP 1: Enter access token ──────────────────────────────────────── */} + {step === 1 && ( +
+

Step 1 — Enter your WAWP Access Token

+

+ Your access token is found in your WAWP account dashboard at{" "} + + app.wawp.net + + . +

+
+ + setInputToken(e.target.value)} + onKeyDown={e => e.key === "Enter" && saveToken()} + placeholder="Paste your WAWP access token" + className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ )} + + {/* ── STEP 2: Instance ID ─────────────────────────────────────────────── */} + {step === 2 && ( +
+
+

Step 2 — Set Up Session Instance

+ + Token: {cfg?.tokenMasked} + +
+

+ You need a WAWP session instance. Either create a brand-new one, or enter an + existing Instance ID. +

+ + {/* Tab toggle */} +
+ + +
+ + {instanceMode === "create" && ( +
+

+ Click below to create a new WAWP session. The Instance ID will be saved + automatically. +

+ +
+ )} + + {instanceMode === "enter" && ( +
+
+ + setInputInstanceId(e.target.value)} + onKeyDown={e => e.key === "Enter" && saveInstanceId()} + placeholder="e.g. BF14B761C364" + className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ )} + + +
+ )} + + {/* ── STEP 3: Full management ─────────────────────────────────────────── */} + {step === 3 && ( + <> + {/* Status card */} +
+
+

Session Status

+ +
+ + {status === null ? ( +
+ Fetching status… +
+ ) : ( +
+ {STATUS_ICONS[status] ?? "⚪"} + + {status} + +
+ )} + + {statusMsg &&

{statusMsg}

} + + {status === "FAILED" && ( + + The session has failed. The system will attempt to auto-restart. You can also + restart manually below. + + )} + + {status && POLLING_STATUSES.has(status) && ( +

+ Auto-refreshing every 5 seconds… +

+ )} + + {/* Config info strip */} +
+ + Token: {cfg?.tokenMasked || "—"} + + + Instance: {cfg?.instanceId || "—"} + +
+
+ + {/* QR Code */} + {status === "SCAN_QR_CODE" && ( +
+
+

Scan QR Code

+ +
+

+ Open WhatsApp → Linked Devices → Link a Device, then scan the code below. +

+ {qrSrc ? ( + WhatsApp QR Code + ) : ( +
+ Loading QR… +
+ )} +

+ QR codes expire after ~20 seconds — click Refresh QR if it stops working. +

+
+ )} + + {/* Pairing code */} + {status === "SCAN_QR_CODE" && ( +
+

Link by Phone Number Instead

+

+ Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing + code on your phone. +

+
+ setPairingPhone(e.target.value)} + placeholder="082 123 4567" + className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + +
+
+ )} + + {/* Session controls */} +
+

Session Controls

+
+ doAction("start")} + /> + doAction("restart")} + /> + { + if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return; + doAction("logout"); + }} + /> +
+
+ + {/* Instance management */} +
+

Instance Management

+

+ Create a brand-new instance or permanently delete the current one. Deleting will + require you to set up a new instance. +

+
+ doAction("create-instance")} + /> + { + if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return; + doAction("delete-instance"); + }} + /> +
+
+ + {/* Update credentials */} +
+ + Update Credentials + expand ▾ + +
+

+ Change your WAWP access token or Instance ID. Leave a field blank to keep the + current value. +

+
+ + setInputToken(e.target.value)} + placeholder="Leave blank to keep current token" + className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+
+ + setInputInstanceId(e.target.value)} + placeholder="Leave blank to keep current instance" + className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+
+ + )} + +

+ WhatsApp notifications powered by{" "} + + WAWP + + . Session auto-recovers on failure; admin alert sent if recovery fails. +

+
+ ); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StepIndicator({ step }: { step: number }) { + const steps = [ + { n: 1, label: "Access Token" }, + { n: 2, label: "Session Instance" }, + { n: 3, label: "Connected" }, + ]; + return ( +
+ {steps.map((s, i) => { + const done = step > s.n; + const current = step === s.n; + return ( + +
+
+ {done ? "✓" : s.n} +
+ + {s.label} + +
+ {i < steps.length - 1 && ( +
+ )} + + ); + })} +
+ ); +} + +type ButtonColor = "green" | "amber" | "blue" | "red-outline"; + +function ActionButton({ + label, + busyLabel, + isBusy, + disabled, + color, + onClick, +}: { + label: string; + busyLabel: string; + isBusy: boolean; + disabled: boolean; + color: ButtonColor; + onClick: () => void; +}) { + const base = "flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 transition-colors"; + const colors: Record = { + green: "bg-green-600 text-white hover:bg-green-700", + amber: "bg-amber-500 text-white hover:bg-amber-600", + blue: "bg-blue-600 text-white hover:bg-blue-700", + "red-outline": "border border-red-600 text-red-600 hover:bg-red-50", + }; + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..bd60444 --- /dev/null +++ b/frontend/src/app/dashboard/layout.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React, { useEffect } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { usePathname, useRouter } from "next/navigation"; +import { Navbar } from "@/components/layout/Navbar"; +import { Footer } from "@/components/layout/Footer"; +import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar"; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + + // Redirect unauthenticated users to login + useEffect(() => { + if (loading) return; + if (!user) { + router.replace("/login"); + } + }, [user, loading, router]); + + // Role-based route guard within /dashboard + useEffect(() => { + if (loading) return; + if (!user) return; // handled above + if (!pathname || !pathname.startsWith("/dashboard")) return; + + const role = user.role || "user"; + + // Determine allowed prefixes and default destination per role + let allowed: string[] = ["/dashboard/user"]; // everyone can access user dashboard + let dest = "/dashboard/user"; + + if (role === "admin") { + allowed = ["/dashboard/admin", "/dashboard/user"]; + dest = "/dashboard/admin"; + } else if (role === "supervisor") { + allowed = ["/dashboard/supervisor", "/dashboard/user"]; + dest = "/dashboard/supervisor"; + } else if (role === "staff") { + allowed = ["/dashboard/staff", "/dashboard/user"]; + dest = "/dashboard/staff"; + } + + // Allow admin to access supervisor and staff subpages (but not their root dashboards) + const isAllowed = + allowed.some(prefix => pathname === prefix || pathname.startsWith(prefix + "/")) || + (role === "admin" && (pathname.startsWith("/dashboard/supervisor/") || pathname.startsWith("/dashboard/staff/"))) || + (role === "supervisor" && pathname.startsWith("/dashboard/staff/")); + + if (!isAllowed && pathname !== dest) { + router.replace(dest); + } + }, [pathname, user, loading, router]); + + // Don't render protected content until auth state is known + if (loading || !user) { + return ( +
+
Loading…
+
+ ); + } + + return ( +
+ +
+ {/* Mobile dropdown navigation */} + + {/* Desktop sidebar */} +
+ +
+
{children}
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..b38175d --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,32 @@ +"use client"; +import React, { useEffect } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; + +export default function DashboardIndex() { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (loading) return; // wait for auth to initialize + if (!user) { + router.replace("/login"); + return; + } + // Role-aware routing + const role = user.role || "user"; + if (role === "admin") router.replace("/dashboard/admin"); + else if (role === "supervisor") router.replace("/dashboard/supervisor"); + else if (role === "staff") router.replace("/dashboard/staff"); + else router.replace("/dashboard/user"); + }, [router, user, loading]); + + // This route only ever exists to redirect elsewhere (login now goes straight to the role + // dashboard — see LoginForm — so this mainly serves bookmarks/direct links); show a deliberate + // loading state instead of a blank screen while that redirect is worked out. + return ( +
+
Loading…
+
+ ); +} diff --git a/frontend/src/app/dashboard/staff/event-tickets/page.tsx b/frontend/src/app/dashboard/staff/event-tickets/page.tsx new file mode 100644 index 0000000..7d7fd85 --- /dev/null +++ b/frontend/src/app/dashboard/staff/event-tickets/page.tsx @@ -0,0 +1,397 @@ +"use client"; + +import React, { Suspense, useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { apiFetch } from "@/lib/api"; +import { useRouter, useSearchParams } from "next/navigation"; +import { formatDate } from "@/lib/date"; +import { QrImage } from "@/components/shared/QrImage"; + +function EventTicketsContent() { + const { token, user } = useAuth(); + const params = useSearchParams(); + const router = useRouter(); + + const paramEventId = params.get("eventId") || ""; + const paramUserId = params.get("userId") || ""; + + const [events, setEvents] = useState([]); + const [eventId, setEventId] = useState(paramEventId); + const [tickets, setTickets] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const [selectedUserId, setSelectedUserId] = useState(paramUserId); + const [selectedVariantId, setSelectedVariantId] = useState(""); + + const canView = useMemo(() => { + const role = user?.role; + return role === "admin" || role === "supervisor" || role === "staff"; + }, [user]); + + // Load events for dropdown + useEffect(() => { + (async () => { + try { + if (!token) return; + const evs = await apiFetch("/api/events/all", { authToken: token }); + const now = Date.now(); + const active = (evs || []).filter((ev) => { + const t = new Date(ev.endDate).getTime(); + return !isNaN(t) && t > now; + }); + active.sort( + (a, b) => + new Date(a.startDate).getTime() - new Date(b.startDate).getTime() + ); + setEvents(active); + if (!paramEventId && active.length > 0) { + setEventId(active[0].id); + router.replace( + `?eventId=${encodeURIComponent(active[0].id)}${ + selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : "" + }` + ); + } + } catch (e: any) { + setError(e?.message || "Failed to load events"); + } + })(); + }, [token]); + + // Sync params → state + useEffect(() => { + if (paramEventId && paramEventId !== eventId) setEventId(paramEventId); + if (paramUserId && paramUserId !== selectedUserId) + setSelectedUserId(paramUserId); + }, [paramEventId, paramUserId]); + + // Load tickets when event changes + useEffect(() => { + (async () => { + if (!token || !eventId) return; + setLoading(true); + setError(null); + try { + const list = await apiFetch( + `/api/tickets/event/${encodeURIComponent(eventId)}`, + { authToken: token } + ); + setTickets(list); + } catch (e: any) { + setError(e?.message || "Failed to load tickets"); + } finally { + setLoading(false); + } + })(); + }, [token, eventId]); + + // Build variants list for filter (only variants present in current event's tickets) + const variantsList = useMemo(() => { + const map = new Map(); + for (const t of tickets) { + const v = t.registrationOption?.variant; + if (v?.id) map.set(v.id, v.name); + } + return Array.from(map.entries()) + .map(([id, name]) => ({ id, name })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [tickets]); + + // Build users list depending on event selection + const usersList = useMemo(() => { + let source = [...tickets]; + if (eventId) { + source = source.filter((t) => String(t.eventId) === String(eventId)); + } + const map = new Map(); + for (const t of source) { + const uid = t.user?.id || t.userId; + if (!uid) continue; + const name = t.user?.name || t.user?.email || String(uid); + if (!map.has(String(uid))) map.set(String(uid), name); + } + return Array.from(map.entries()) + .map(([id, name]) => ({ id, name })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [tickets, eventId]); + + // Filter tickets by event + user + variant + const filteredTickets = useMemo(() => { + let list = [...tickets]; + if (eventId) { + list = list.filter((t) => String(t.eventId) === String(eventId)); + } + if (selectedUserId) { + list = list.filter( + (t) => String(t.user?.id || t.userId) === String(selectedUserId) + ); + } + if (selectedVariantId) { + list = list.filter( + (t) => String(t.registrationOption?.variant?.id || "") === String(selectedVariantId) + ); + } + return list; + }, [tickets, eventId, selectedUserId, selectedVariantId]); + + // Printing helpers + const buildTicketHtmlCard = (t: any) => { + const eventTitle = t.event?.title || t.eventId || "Event"; + const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null; + const optBase = t.registrationOption?.eventOption?.name || "Ticket"; + const optVariant = t.registrationOption?.variant?.name; + const type = optVariant ? `${optBase} — ${optVariant}` : optBase; + const qty = t.quantity || 1; + const holder = t.user?.name || t.userId || ""; + const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent( + t.qrCode || t.id + )}`; + const dateStr = eventDate ? formatDate(eventDate) : ""; + return ` +
+
${eventTitle}
+ ${dateStr ? `
${dateStr}
` : ""} +
${type}
+
Qty: ${qty}
+
QR
+
ID: ${t.id}
+ ${holder ? `
${holder}
` : ""} +
+ `; + }; + + const openPrintWindow = (pagesHtml: string) => { + const w = window.open("", "_blank"); + if (!w) return; + w.document.write(`Tickets + + + ${pagesHtml} + + `); + w.document.close(); + w.focus(); + }; + + const printTickets = (ticketList: any[]) => { + if (!ticketList || ticketList.length === 0) return; + // Chunk into pages of 8 (2 columns × 4 rows per A4 sheet) + const PAGE_SIZE = 8; + const pages: string[] = []; + for (let i = 0; i < ticketList.length; i += PAGE_SIZE) { + const chunk = ticketList.slice(i, i + PAGE_SIZE); + pages.push(`
${chunk.map(buildTicketHtmlCard).join("")}
`); + } + openPrintWindow(pages.join("")); + }; + + return ( +
+
+

Event Tickets

+ +
+

+ View and print tickets for an event. +

+ + {!canView && ( +
+ You need staff, supervisor, or admin access to view this page. +
+ )} + +
+ + + + + + + {variantsList.length > 0 && ( + <> + + + + )} + + {filteredTickets.length > 0 && ( +
+ + +
+ )} +
+ + {loading &&
Loading...
} + {error &&
{error}
} + + {filteredTickets.length > 0 && ( +
+ + Total tickets: {filteredTickets.length} + + + Used: {filteredTickets.filter((t) => t.isUsed).length} + + + Unused: {filteredTickets.filter((t) => !t.isUsed).length} + +
+ )} + +
+ {filteredTickets.map((t) => { + const eventDate = t.event?.startDate + ? new Date(t.event.startDate) + : null; + const optBase = t.registrationOption?.eventOption?.name || "Ticket"; + const optVariant = t.registrationOption?.variant?.name; + const type = optVariant ? `${optBase} — ${optVariant}` : optBase; + return ( +
+
+
+ {t.event?.title || t.eventId} +
+ {eventDate && ( + + {formatDate(eventDate)} + + )} +
+
{type}
+
+ +
+
+ #{String(t.id).slice(0, 8)} •{" "} + {t.isUsed ? "Used" : "Unused"} +
+
+ ); + })} +
+
+ ); +} + +export default function EventTicketsPage() { + return ( + Loading...
}> + + + ); +} diff --git a/frontend/src/app/dashboard/staff/page.tsx b/frontend/src/app/dashboard/staff/page.tsx new file mode 100644 index 0000000..c80ea4a --- /dev/null +++ b/frontend/src/app/dashboard/staff/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { apiFetch } from "@/lib/api"; +import { useStableState } from "@/hooks/useStableState"; +import { useVisiblePolling } from "@/hooks/useVisiblePolling"; + +export default function StaffDashboardPage() { + const { user, loading, token } = useAuth(); + const router = useRouter(); + + // Access control (staff/supervisor/admin) + const canView = useMemo(() => { + const role = user?.role; + return role === "admin" || role === "supervisor" || role === "staff"; + }, [user]); + + useEffect(() => { + if (loading) return; + if (!user) router.replace("/login"); + }, [user, loading, router]); + + // Stats and recent scans for quick overview — both come from one endpoint + // (/api/stats/staff) instead of two separate calls. useStableState skips re-renders when + // a poll returns identical data, and hasLoadedOnce below means "Refreshing…" only shows + // on the very first load — together these stop the stats panel from flickering. + const [stats, setStats] = useStableState(null); + const [recentScans, setRecentScans] = useStableState([]); + const [loadingStats, setLoadingStats] = useState(false); + const hasLoadedOnce = useRef(false); + + const loadStats = async () => { + if (!token) return; + const isFirstLoad = !hasLoadedOnce.current; + try { + if (isFirstLoad) setLoadingStats(true); + const data = await apiFetch("/api/stats/staff", { authToken: token }); + setStats(data.scanStats); + setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []); + } catch (e) { + // ignore + } finally { + hasLoadedOnce.current = true; + if (isFirstLoad) setLoadingStats(false); + } + }; + + useEffect(() => { + if (!token) return; + loadStats(); + }, [token]); + + // Poll every 10s while the tab is visible; pause in the background and refetch + // immediately on return instead of leaving stale numbers up. + useVisiblePolling(() => { + if (!token) return; + loadStats(); + }, 10000, !!token); + + return ( +
+
+

Staff Dashboard{user ? ` — ${user.name}` : ""}

+
+ + +
+
+ + {!canView && ( +
+ You need staff, supervisor, or admin access to use staff tools. +
+ )} + +
+
+
+
Quick actions
+
+ + +
+
+ +
+
+

Recent scans

+ {loadingStats && Refreshing…} +
+
    + {recentScans.map((u: any) => ( +
  • +
    +
    {u.ticket?.event?.title || u.ticket?.eventId || 'Event'}
    +
    {new Date(u.scannedAt).toLocaleString()}
    +
    +
    {u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}
    +
    Scanned by: {u.scannedBy?.name || u.scannedById}
    +
  • + ))} + {recentScans.length === 0 &&
  • No scans yet.
  • } +
+
+
+ +
+
+

Scanner stats

+ {loadingStats &&
Loading stats…
} + {stats && ( +
+
+
Today
+
{stats.totalToday}
+
+
+
My scans
+
{stats.myToday}
+
+
+
Last hour
+
{stats.lastHour}
+
+
+ )} + + {stats?.byStaff?.length > 0 && ( +
+
Today by staff
+
    + {stats.byStaff.map((s: any) => ( +
  • + {s.name || 'Staff'} + {s.count} +
  • + ))} +
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx new file mode 100644 index 0000000..98364ad --- /dev/null +++ b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx @@ -0,0 +1,487 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner"; +import { useAuth } from "@/hooks/useAuth"; +import { apiFetch } from "@/lib/api"; +import { useRouter } from "next/navigation"; + +export default function TicketScanningPage() { + const router = useRouter(); + const scannerRef = useRef(null); + const [lastResult, setLastResult] = useState(null); + const [scanInfo, setScanInfo] = useState(null); + const [error, setError] = useState(null); + const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null); + const [errorModal, setErrorModal] = useState(null); + const { token, user } = useAuth(); + const [recentScans, setRecentScans] = useState([]); + const [stats, setStats] = useState(null); + const [loadingStats, setLoadingStats] = useState(false); + const [successModal, setSuccessModal] = useState<{ + optionName: string; + ticketId: string; + eventTitle: string; + qtyRedeemed: number; + remaining: number; + total: number; + } | null>(null); + + // Confirm step state + const [confirmModal, setConfirmModal] = useState<{ + qrCode: string; + ticket: any; + remaining: number; + } | null>(null); + const [confirmQty, setConfirmQty] = useState(1); + const [confirmQtyRaw, setConfirmQtyRaw] = useState("1"); + + // Event selection state + const [allEvents, setAllEvents] = useState([]); + const [includePastEvents, setIncludePastEvents] = useState(false); + const [selectedEventId, setSelectedEventId] = useState("all"); + const [loadingEvents, setLoadingEvents] = useState(false); + const [sections, setSections] = useState([]); + const [selectedSectionId, setSelectedSectionId] = useState("all"); + const [loadingSections, setLoadingSections] = useState(false); + + // All events from API (including past); frontend filters to 12h window or all past + useEffect(() => { + (async () => { + try { + if (!token) return; + setLoadingEvents(true); + const evs = await apiFetch("/api/events/all?includePast=true", { authToken: token }); + const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime()); + setAllEvents(sorted); + } catch (e: any) { + // non-fatal + } finally { + setLoadingEvents(false); + } + })(); + }, [token]); + + const events = useMemo(() => { + if (includePastEvents) return allEvents; + const cutoff = Date.now() - 12 * 60 * 60 * 1000; + return allEvents.filter(ev => { + const t = new Date(ev.endDate).getTime(); + return !isNaN(t) && t >= cutoff; + }); + }, [allEvents, includePastEvents]); + + useEffect(() => { + (async () => { + if (!token || !selectedEventId || selectedEventId === "all") { + setSections([]); + setSelectedSectionId("all"); + return; + } + try { + setLoadingSections(true); + const list = await apiFetch(`/api/sections?eventId=${selectedEventId}`, { authToken: token }); + setSections(Array.isArray(list) ? list : []); + setSelectedSectionId("all"); + } catch { + setSections([]); + } finally { + setLoadingSections(false); + } + })(); + }, [selectedEventId, token]); + + // Bug fix: stop the scanner when the event or section filter changes so stale scan + // context is never used. The user must press "Scan Ticket" again to resume. + const isFirstFilterRender = useRef(true); + useEffect(() => { + if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; } + scannerRef.current?.stop(); + }, [selectedEventId, selectedSectionId]); + + async function handleResult(text: string) { + setLastResult(text); + setError(null); + setScanInfo(null); + try { + if (!token) { + setErrorModal("Please login as staff to scan tickets."); + return; + } + + // Always preview first to validate the ticket before showing confirm step + // Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal) + let preview: any; + try { + preview = await apiFetch(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token }); + } catch (pe: any) { + setErrorModal(pe?.message || 'Failed to validate ticket'); + return; + } + + // Validate event + if (selectedEventId && selectedEventId !== "all") { + const ticketEventId = preview?.event?.id || preview?.eventId; + if (ticketEventId && ticketEventId !== selectedEventId) { + const eventTitle = preview?.event?.title || 'Event'; + setErrorModal(`This ticket is for a different event: ${eventTitle}`); + return; + } + + // Validate section + if (selectedSectionId !== "all") { + const ticketOptionId = preview?.registrationOption?.eventOption?.id || preview?.eventOptionId; + if (!ticketOptionId) { + setErrorModal("Unable to determine ticket type"); + return; + } + const section = sections.find(s => s.id === selectedSectionId); + const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId); + if (!allowedOptionIds.includes(ticketOptionId)) { + setErrorModal("This ticket is not valid for this section"); + return; + } + } + } + + // Compute remaining qty + const ticketQty = preview?.quantity || 1; + const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0); + const remaining = ticketQty - totalRedeemed; + + if (remaining <= 0) { + setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview }); + return; + } + + // Show confirm step + setConfirmQty(remaining); + setConfirmQtyRaw(String(remaining)); + setConfirmModal({ qrCode: text, ticket: preview, remaining }); + } catch (e: any) { + setErrorModal(e?.message || "Failed to scan ticket"); + } + } + + async function commitScan() { + if (!confirmModal || !token) return; + + // Capture everything before clearing modal state + const { qrCode, ticket, remaining } = confirmModal; + const qty = confirmQty; + const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket"; + const variantName = ticket?.registrationOption?.variant?.name; + const optionName = variantName ? `${baseName} — ${variantName}` : baseName; + const eventTitle = ticket?.event?.title || 'Event'; + const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : ''; + const newRemaining = remaining - qty; + + // Show success immediately — we already have all the data from the preview step. + // The actual write happens in the background so the user sees an instant response. + setConfirmModal(null); + setScanInfo(`Ticket #${idPart} — ${optionName} — ${eventTitle}`); + setSuccessModal({ + optionName, + ticketId: idPart, + eventTitle, + qtyRedeemed: qty, + remaining: newRemaining, + total: ticket?.quantity || 1, + }); + + // Background write — reverse the optimistic update on failure + try { + const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${ + selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : '' + }`; + await apiFetch(url, { + method: "POST", + authToken: token, + body: { qty }, + }); + refreshStatsAndRecent(); + } catch (e: any) { + setSuccessModal(null); + setScanInfo(null); + const raw = e?.message || "Failed to scan ticket"; + try { + const parsed = JSON.parse(raw); + const msg: string | undefined = parsed?.message; + if (msg && /already\s*(been)?\s*used/i.test(msg)) { + setAlreadyUsedModal({ message: msg, ticket: parsed?.ticket }); + return; + } + setErrorModal(msg || raw); + } catch { + setErrorModal(raw); + } + } + } + + const loadRecent = async () => { + if (!token) return; + try { + const list = await apiFetch(`/api/tickets/scans/recent?limit=10`, { authToken: token }); + setRecentScans(list); + } catch {} + }; + const loadStats = async () => { + if (!token) return; + try { + setLoadingStats(true); + const s = await apiFetch(`/api/tickets/scans/stats`, { authToken: token }); + setStats(s); + } catch {} finally { + setLoadingStats(false); + } + }; + const refreshStatsAndRecent = () => { loadRecent(); loadStats(); }; + useEffect(() => { + refreshStatsAndRecent(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + return ( +
+
+
+
+

Ticket Scanning

+ +
+

+ Use the button to start/stop scanning. The back camera will be used when available. +

+ + {/* Event filter */} +
+ + + + {loadingEvents && Loading events…} +
+ {sections.length > 0 && ( +
+ + + {loadingSections && Loading sections…} +
+ )} + + + + {scanInfo && ( +
{scanInfo}
+ )} + {error && ( +
{error}
+ )} +
+ +
+

Scanner Stats

+ {loadingStats &&
Loading stats…
} + {stats && ( +
+
+
Today
+
{stats.totalToday}
+
+
+
My scans
+
{stats.myToday}
+
+
+
Last hour
+
{stats.lastHour}
+
+
+ )} + + {stats?.byStaff?.length > 0 && ( +
+
Today by staff
+
    + {stats.byStaff.map((s: any) => ( +
  • + {s.name || 'Staff'} + {s.count} +
  • + ))} +
+
+ )} + +

Last 10 scans

+
    + {recentScans.map((u: any) => ( +
  • +
    +
    {u.ticket?.event?.title || 'Event'}
    +
    {new Date(u.scannedAt).toLocaleString()}
    +
    +
    + {u.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{u.ticket?.registrationOption?.variant?.name ? ` — ${u.ticket.registrationOption.variant.name}` : ''} + {u.quantityRedeemed && u.quantityRedeemed > 1 ? ` ×${u.quantityRedeemed}` : ''} + {' — '}#{String(u.ticket?.id || '').slice(0,8)} +
    +
    Scanned by: {u.scannedBy?.name || u.scannedById}
    +
  • + ))} + {recentScans.length === 0 &&
  • No scans yet.
  • } +
+
+
+ + {/* ── Confirm scan modal ───────────────────────────────────────────── */} + {confirmModal && ( +
+
+

Confirm Scan

+

Review the ticket details before confirming.

+ +
+
Ticket type: {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}
+
Event: {confirmModal.ticket?.event?.title || '—'}
+
Holder: {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}
+
Qty on ticket: {confirmModal.ticket?.quantity || 1}
+
Remaining: {confirmModal.remaining}
+
+ + {(confirmModal.ticket?.quantity || 1) > 1 && ( +
+ + setConfirmQtyRaw(e.target.value)} + onBlur={() => { + const parsed = parseInt(confirmQtyRaw, 10); + const clamped = isNaN(parsed) ? 1 : Math.min(confirmModal.remaining, Math.max(1, parsed)); + setConfirmQty(clamped); + setConfirmQtyRaw(String(clamped)); + }} + /> +
+ )} + +
+ + +
+
+
+ )} + + {/* ── Already used modal ───────────────────────────────────────────── */} + {alreadyUsedModal && ( +
+
+

Ticket Already Scanned

+

{alreadyUsedModal.message}

+ {alreadyUsedModal.ticket && ( +
+
Ticket ID: {alreadyUsedModal.ticket.id}
+ {alreadyUsedModal.ticket.event?.title && ( +
Event: {alreadyUsedModal.ticket.event.title}
+ )} + {Array.isArray(alreadyUsedModal.ticket.usages) && alreadyUsedModal.ticket.usages.length > 0 && ( +
+
Usages:
+
    + {alreadyUsedModal.ticket.usages.map((u: any) => ( +
  • {new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}
  • + ))} +
+
+ )} +
+ )} +
+ +
+
+
+ )} + + {/* ── Error modal ──────────────────────────────────────────────────── */} + {errorModal && ( +
+
+

Scan Error

+

{errorModal}

+
+ +
+
+
+ )} + + {/* ── Success modal ────────────────────────────────────────────────── */} + {successModal && ( +
+
+

Scan Successful

+ +
+ {successModal.optionName} +
+ +
+ #{successModal.ticketId} — {successModal.eventTitle} +
+ + {successModal.total > 1 && ( +
0 ? 'text-amber-600' : 'text-green-700'}`}> + {successModal.qtyRedeemed} redeemed · {successModal.remaining} remaining of {successModal.total} +
+ )} + +
+ +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx new file mode 100644 index 0000000..d0aa52d --- /dev/null +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -0,0 +1,1872 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { apiFetch } from "@/lib/api"; +import { scoreUser } from "@/lib/fuzzyMatch"; + +type Mode = "registration" | "payment" | "tickets" | "refund"; + +// ─── Fuzzy search helpers ───────────────────────────────────────────────────── + +function fuzzyFilterRegs(allRegs: any[], search: string): any[] { + if (!search) return allRegs; + if (search.length === 1) { + const q = search.toLowerCase(); + return allRegs.filter(r => + String(r.user?.name || "").toLowerCase().includes(q) || + String(r.user?.email || "").toLowerCase().includes(q) || + String(r.user?.phoneNumber || "").includes(q) + ); + } + return allRegs + .map(r => ({ r, score: scoreUser(r.user || {}, search) })) + .filter(x => x.score >= 0.45) + .sort((a, b) => b.score - a.score) + .map(x => x.r); +} + +// ─── Pricing helpers ───────────────────────────────────────────────────────── + +function effectiveOptionUnit(opt: any): number { + const base = opt.price || 0; + const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t: any) => !t.variantId); + if (!tiers.length) return base; + const now = new Date(); + const hit = tiers + .map((t: any) => ({ ...t, deadline: new Date(t.deadline) })) + .filter((t: any) => now < t.deadline) + .sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price); + return hit.length ? hit[0].price : base; +} + +function effectiveVariantUnit(opt: any, variant: any): number { + const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0; + const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []; + const vtiers = allTiers.filter((t: any) => t.variantId === variant.id); + const tiers = vtiers.length ? vtiers : allTiers.filter((t: any) => !t.variantId); + if (!tiers.length) return base; + const now = new Date(); + const hit = tiers + .map((t: any) => ({ ...t, deadline: new Date(t.deadline) })) + .filter((t: any) => now < t.deadline) + .sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price); + return hit.length ? hit[0].price : base; +} + +function initQuantities(eventOptions: any[]): Record { + const map: Record = {}; + (eventOptions || []).forEach((o: any) => { + if ((o.variants || []).length > 0) { + (o.variants as any[]).forEach((v: any) => { map[`${o.id}::${v.id}`] = 0; }); + } else { + map[o.id] = 0; + } + }); + return map; +} + +function ticketLabel(t: any): string { + const opt = t.registrationOption?.eventOption?.name || "Ticket"; + const variant = t.registrationOption?.variant?.name; + return variant ? `${opt} — ${variant}` : opt; +} + +export default function AtTheDoorPage() { + const { user, loading, token } = useAuth(); + const router = useRouter(); + const [events, setEvents] = useState([]); + const [eventId, setEventId] = useState(""); + + const [eventOptions, setEventOptions] = useState([]); + const [showOptionsModal, setShowOptionsModal] = useState(false); + const [pendingUser, setPendingUser] = useState(null); + const [pendingEditReg, setPendingEditReg] = useState(null); + const [confirming, setConfirming] = useState(false); + const [quantities, setQuantities] = useState>({}); + // Already-issued ticket quantity per option/variant, keyed like `quantities` — a + // registration being edited can never drop an item below this (tickets are never + // deleted or shrunk, only ever grown, once paid). + const [minQuantities, setMinQuantities] = useState>({}); + + const [showDonationModal, setShowDonationModal] = useState(false); + const [donationUser, setDonationUser] = useState(null); + + const [showNewAttendeeModal, setShowNewAttendeeModal] = useState(false); + const [newAttendeeSeed, setNewAttendeeSeed] = useState(""); + + useEffect(() => { + if (!token) return; + (async () => { + try { + const evs = await apiFetch("/api/events/all", { authToken: token }); + const now = Date.now(); + + const active = (evs || []).filter(ev => { + const t = new Date(ev.endDate).getTime(); + // Registrations/payments are rejected server-side for closed (cashed-up) + // events — don't offer them here. + return !isNaN(t) && t > now && ev.cashupStatus !== 'closed'; + }); + + active.sort( + (a, b) => + new Date(a.startDate).getTime() - + new Date(b.startDate).getTime() + ); + + setEvents(active); + + if (active.length > 0) { + setEventId(active[0].id); // default + } + } catch {} + })(); + }, [token]); + + useEffect(() => { + if (!token || !eventId) return; + + (async () => { + try { + const ev = await apiFetch(`/api/events/${eventId}`, { + authToken: token + }); + + setEventOptions(ev.options || ev.eventOptions || []); + } catch { + setEventOptions([]); + } + })(); + }, [eventId, token]); + + const canView = useMemo(() => { + const role = user?.role; + return role === "admin" || role === "supervisor" || role === "staff"; + }, [user]); + + useEffect(() => { + if (loading) return; + if (!user) router.replace("/login"); + }, [user, loading]); + + const [mode, setMode] = useState("registration"); + + const [activeRegistration, setActiveRegistration] = useState(null); + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!info) return; + + const id = setTimeout(() => { + setInfo(null); + }, 10000); // ⏱ disappears after 5s + + return () => clearTimeout(id); + + }, [info]); + + useEffect(() => { + if (!error) return; + + const id = setTimeout(() => { + setError(null); + }, 15000); // errors linger slightly longer + + return () => clearTimeout(id); + + }, [error]); + + const handleRegistrationCreated = (registration: any) => { + setActiveRegistration(registration); + setMode("payment"); // 🚀 Jump automatically + }; + + const handleRegistrationSelected = (registration: any) => { + setActiveRegistration(registration); + }; + + useEffect(() => { + if (!activeRegistration) return; + + const id = setTimeout(() => { + setMode("payment"); + }, 0); + + return () => clearTimeout(id); + + }, [activeRegistration]); + + const confirmRegistration = async () => { + if (confirming) return; + + const opts = Object.entries(quantities) + .filter(([, qty]) => qty > 0) + .map(([key, quantity]) => { + const [eventOptionId, variantId] = key.split("::"); + return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) }; + }); + + if (!opts.length) { + setError("Select at least one item"); + return; + } + + for (const [key, minQty] of Object.entries(minQuantities)) { + if (minQty > 0 && (quantities[key] || 0) < minQty) { + const [eventOptionId, variantId] = key.split("::"); + const opt = eventOptions.find((o: any) => o.id === eventOptionId); + const variant = variantId ? (opt?.variants || []).find((v: any) => v.id === variantId) : null; + const label = variant ? `${opt?.name || "item"} (${variant.name})` : (opt?.name || "item"); + setError(`Cannot reduce "${label}" below the ${minQty} already issued`); + return; + } + } + + setConfirming(true); + try { + if (pendingEditReg) { + // Edit existing registration — replace options via PUT + const res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, { + method: "PUT", + authToken: token, + body: { options: opts } + }); + setShowOptionsModal(false); + setPendingEditReg(null); + setInfo("Registration updated"); + handleRegistrationSelected(res); + } else if (pendingUser) { + // New attendee + const res = await apiFetch("/api/registrations/manual", { + method: "POST", + authToken: token, + body: { + eventId, + guestOnly: pendingUser.guestOnly, + user: { + name: pendingUser.name, + ...(pendingUser.email ? { email: pendingUser.email } : {}), + ...(pendingUser.phone ? { phoneNumber: pendingUser.phone } : {}), + }, + options: opts, + notificationPreference: pendingUser.notifPref, + } + }); + setShowOptionsModal(false); + handleRegistrationCreated(res); + } + } catch (e: any) { + setError(e?.message || "Failed"); + } finally { + setConfirming(false); + } + }; + + // Opens the new-attendee modal (pre-filled with whatever the user typed in search) + const handleShowNewAttendee = (seed: string) => { + setNewAttendeeSeed(seed); + setShowNewAttendeeModal(true); + }; + + // Called when NewAttendeeModal is confirmed — proceed to options selection + const handleNewAttendeeConfirm = ({ name, email, phone, notifPref }: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => { + const qtyMap = initQuantities(eventOptions); + // Default main ticket to 1 (first variant if variants exist) + eventOptions.forEach(o => { + if (!o.isMainTicket) return; + if ((o.variants || []).length > 0) { + qtyMap[`${o.id}::${(o.variants as any[])[0].id}`] = 1; + } else { + qtyMap[o.id] = 1; + } + }); + setQuantities(qtyMap); + setMinQuantities({}); + setPendingUser({ guestOnly: true, name, email: email || null, phone: phone || null, notifPref }); + setPendingEditReg(null); + setShowNewAttendeeModal(false); + setShowOptionsModal(true); + }; + + // Edit an existing registration — pre-fill with current quantities. + // Re-fetches the registration fresh rather than trusting the (possibly up to 5-minutes-stale, + // see DoorRegistrationPanel's polling interval) cached search-result snapshot, so the + // already-issued-ticket floor below is always computed from current data. + const handleEditRegistration = async (reg: any) => { + let freshReg = reg; + try { + freshReg = await apiFetch(`/api/registrations/${reg.id}`, { authToken: token }); + } catch (e: any) { + setError(e?.message || "Failed to load latest registration data"); + return; + } + + const qtyMap = initQuantities(eventOptions); + const regOptions = freshReg.registrationOptions || freshReg.options || []; + regOptions.forEach((ro: any) => { + const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId; + if (key in qtyMap) qtyMap[key] = ro.quantity || 0; + }); + + // Already-issued ticket quantity per option/variant — floor for the edit below + const minQtyMap: Record = {}; + regOptions.forEach((ro: any) => { + const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId; + const issuedQty = (ro.tickets || []).reduce((s: number, t: any) => s + (t.quantity || 0), 0); + if (issuedQty > 0) minQtyMap[key] = (minQtyMap[key] || 0) + issuedQty; + }); + + setQuantities(qtyMap); + setMinQuantities(minQtyMap); + setPendingEditReg(freshReg); + setPendingUser(null); + setShowOptionsModal(true); + }; + + const handlePaymentCaptured = async (registration?: any, partial?: boolean) => { + + if (partial && registration) { + setActiveRegistration(registration); + setInfo("Partial payment recorded"); + return; + } + + if (!registration && !activeRegistration) return; + + const reg = registration || activeRegistration; + + try { + const res = await apiFetch("/api/tickets/generate", { + method: "POST", + authToken: token, + body: { + registrationId: reg.id + } + }); + + const tickets = res?.tickets || []; + + if (tickets.length) { + printTickets(tickets); + } + + setInfo("Payment recorded & tickets printed"); + setActiveRegistration(null); + setMode("registration"); + + } catch (e: any) { + setError(e?.message || "Tickets failed to generate"); + } + }; + + const handleDonation = (user?: any) => { + setDonationUser(user || null); // null = anonymous + setShowDonationModal(true); + }; + + const buildTicketHtmlCard = (t: any) => { + const eventTitle = + t.event?.title || + t.registrationOption?.registration?.event?.title || + "Event"; + + const ticketType = ticketLabel(t); + const qty = t.quantity || 1; + const holder = + t.user?.name || + t.registrationOption?.registration?.user?.name || + ""; + const qrValue = t.qrCode || t.id; + const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`; + + return `
+
+
${eventTitle}
+
${ticketType}
+ ${holder ? `
${holder}
` : ""} +
Qty: ${qty}
+
+
QR
+
${t.id}
+
`; + }; + + const openPrintWindow = (cardsHtml: string) => { + const w = window.open("", "_blank"); + if (!w) return; + w.document.write(`${cardsHtml}`); + w.document.close(); + }; + + const printTickets = (tickets: any[]) => { + if (!tickets?.length) return; + // 4 per A4 page (2 columns × 2 rows) + const PAGE_SIZE = 4; + const pages: string[] = []; + for (let i = 0; i < tickets.length; i += PAGE_SIZE) { + const chunk = tickets.slice(i, i + PAGE_SIZE); + pages.push(`
${chunk.map(buildTicketHtmlCard).join("")}
`); + } + openPrintWindow(pages.join("")); + }; + + + return ( +
+ +
+

At The Door

+ + +
+ + {!canView && ( +
+ Access denied. +
+ )} + + {error && ( +
+ {error} +
+ )} + + {info && ( +
+ {info} +
+ )} + + {/* Mode Buttons */} +
+ {(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => ( + + ))} +
+ + {/* ✅ Panels */} + {mode === "registration" && ( + + )} + + {mode === "payment" && ( + + )} + + {mode === "tickets" && ( + + )} + + {mode === "refund" && ( + + )} + { setShowOptionsModal(false); setPendingEditReg(null); }} + options={eventOptions} + quantities={quantities} + setQuantities={setQuantities} + minQuantities={minQuantities} + onConfirm={confirmRegistration} + confirming={confirming} + isEdit={!!pendingEditReg} + totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)} + /> + setShowDonationModal(false)} + user={donationUser} + token={token} + eventId={eventId} + setError={setError} + /> + setShowNewAttendeeModal(false)} + seed={newAttendeeSeed} + onConfirm={handleNewAttendeeConfirm} + /> +
+ ); +} + +function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRegistration, onNewAttendee, onDonation, setError }: any) { + const [search, setSearch] = useState(""); + const [allRegs, setAllRegs] = useState([]); + const [loading, setLoading] = useState(false); + + // Load registrations for this event — once on mount/eventId change, then every 5 min + useEffect(() => { + if (!token || !eventId) return; + let cancelled = false; + + const load = async () => { + try { + setLoading(true); + const regs = await apiFetch(`/api/registrations/event/${eventId}`, { authToken: token }); + if (!cancelled) setAllRegs(regs || []); + } catch { + if (!cancelled) setAllRegs([]); + } finally { + if (!cancelled) setLoading(false); + } + }; + + load(); + const timer = setInterval(load, 300000); + return () => { cancelled = true; clearInterval(timer); }; + }, [token, eventId]); + + const results = fuzzyFilterRegs(allRegs, search); + + return ( +
+ +
+
Find / Register
+ +
+ + setSearch(e.target.value)} + autoFocus + /> + +
+ + {results.map(r => ( +
+
+
+
{r.user?.name || "Guest"}
+
+ {r.user?.email && !r.user.email.endsWith("@guest.local") ? r.user.email : ""} + {r.user?.phoneNumber ? ` · ${r.user.phoneNumber}` : ""} +
+
+ {r.status === "paid" ? "PAID ✅" : `UNPAID · ${r.status}`} +
+
+
+ + + +
+
+
+ ))} + + {/* New attendee row */} +
onNewAttendee(search)} + > +
+ {search ? `New attendee "${search}"…` : "New attendee…"} +
+
+ Register
+
+ + {!loading && allRegs.length === 0 && ( +
No registrations yet for this event.
+ )} +
+ + +
+ ); +} + +function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) { + const [amount, setAmount] = useState(""); + const [method, setMethod] = useState("card"); + const [saving, setSaving] = useState(false); + const [showRefund, setShowRefund] = useState(false); + const [showSendTickets, setShowSendTickets] = useState(false); + + if (!registration) { + return ( +
+ No registration selected +
+ ); + } + + const options = registration.options || registration.registrationOptions || []; + const payments = registration.payments || []; + + const totalValue = options.reduce((sum: number, opt: any) => { + // Use priceSnapshot (authoritative backend price, variant-aware) if available + const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) + ? Number(opt.priceSnapshot) + : (opt.eventOption?.price ?? opt.price ?? 0); + const qty = opt.quantity || 0; + return sum + price * qty; + }, 0); + + const paidValue = payments.reduce( + (sum: number, p: any) => sum + (p.amount || 0), + 0 + ); + + const balance = Math.max(0, totalValue - paidValue); + + const save = async () => { + if (!balance) { + setError("Nothing due on this registration"); + return; + } + + try { + setSaving(true); + + await apiFetch("/api/payments", { + method: "POST", + authToken: token, + body: { + registrationId: registration.id, + amount: parseFloat(amount), + method + } + }); + + const updated = await apiFetch(`/api/registrations/${registration.id}`, { + authToken: token + }); + + const updatedOptions = updated.options || updated.registrationOptions || []; + const updatedPayments = updated.payments || []; + + const totalValue = updatedOptions.reduce((sum: number, opt: any) => { + const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) + ? Number(opt.priceSnapshot) + : (opt.eventOption?.price ?? opt.price ?? 0); + return sum + price * (opt.quantity || 0); + }, 0); + + const paidValue = updatedPayments.reduce( + (sum: number, p: any) => sum + (p.amount || 0), + 0 + ); + + const updatedBalance = Math.max(0, totalValue - paidValue); + + if (updatedBalance === 0) { + onSuccess(updated); // 🎯 ONLY NOW generate tickets + } else { + onSuccess(updated, true); // 🎯 partial payment flow + } + + } catch (e: any) { + setError(e?.message || "Payment failed"); + } finally { + setSaving(false); + } + }; + + return ( +
+ +
Payment
+ + {/* ✅ User */} +
+
+ {registration.user?.name || "Guest"} +
+
+ + {/* ✅ BIG MONEY BLOCK 😌🔥 */} +
+ +
+
TOTAL
+
+ R {totalValue.toFixed(2)} +
+
+ +
+
PAID
+
+ R {paidValue.toFixed(2)} +
+
+ +
+
DUE
+
+ R {balance.toFixed(2)} +
+
+ +
+ + {/* ✅ Amount + Full Pay */} +
+ + setAmount(e.target.value)} + autoFocus + /> + + {balance > 0 && ( + + )} + +
+ + {/* ✅ Method */} + + + {/* Capture — only show when there's a balance */} + {balance > 0 && ( + + )} + + {/* Print / Send — show when fully paid */} + {balance === 0 && ( +
+ + +
+ )} + + {/* Refund */} + {paidValue > 0 && ( + + )} + + setShowRefund(false)} + token={token} + registration={registration} + maxRefund={paidValue} + onRefunded={(updated: any) => { + setShowRefund(false); + onSuccess(updated, true); + }} + setError={setError} + /> + + setShowSendTickets(false)} + token={token} + registration={registration} + setError={setError} + setInfo={() => {}} + /> +
+ ); +} + +function DoorTicketsPanel({ token, eventId }: any) { + const [search, setSearch] = useState(""); + const [allTickets, setAllTickets] = useState([]); + const [loading, setLoading] = useState(false); + const [sendTarget, setSendTarget] = useState(null); + + // Load all tickets for the event once + useEffect(() => { + if (!token || !eventId) return; + (async () => { + try { + setLoading(true); + const res = await apiFetch(`/api/tickets/event/${eventId}`, { authToken: token }); + setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : [])); + } catch { + setAllTickets([]); + } finally { + setLoading(false); + } + })(); + }, [token, eventId]); + + const tickets = !search + ? allTickets + : (() => { + const q = search.toLowerCase(); + const byId = allTickets.filter(t => + String(t.id).toLowerCase().includes(q) || + String(t.qrCode || "").toLowerCase().includes(q) + ); + if (byId.length > 0) return byId; + if (search.length < 2) return allTickets.filter(t => + String(t.user?.name || "").toLowerCase().includes(q) || + String(t.user?.email || "").toLowerCase().includes(q) + ); + return allTickets + .map(t => ({ t, score: scoreUser(t.user || {}, search) })) + .filter(x => x.score >= 0.45) + .sort((a, b) => b.score - a.score) + .map(x => x.t); + })(); + + const load = async () => { + if (!token || !eventId) return; + try { + setLoading(true); + const res = await apiFetch(`/api/tickets/event/${eventId}`, { authToken: token }); + setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : [])); + } catch { + setAllTickets([]); + } finally { + setLoading(false); + } + }; + + const print = (ticket: any) => { + const w = window.open("", "_blank"); + if (!w) return; + const eventTitle = ticket.event?.title || ticket.eventId || "Event"; + const eventDate = ticket.event?.startDate ? new Date(ticket.event.startDate) : null; + const dateStr = eventDate ? eventDate.toLocaleDateString("en-ZA", { day: "numeric", month: "long", year: "numeric" }) : ""; + const type = ticketLabel(ticket); + const qty = ticket.quantity || 1; + const holder = ticket.user?.name || ticket.userId || ""; + const qrData = encodeURIComponent(ticket.qrCode || ticket.id); + const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${qrData}`; + + w.document.write(`Ticket + + +
+
+
${eventTitle}
+ ${dateStr ? `
${dateStr}
` : ""} +
${type}
+ ${holder ? `
${holder}
` : ""} +
Qty: ${qty}
+
+
QR
+
${ticket.id}
+
+ +`); + w.document.close(); + w.focus(); + }; + + return ( +
+
+
Tickets
+ +
+ +
+ setSearch(e.target.value)} + autoFocus + /> +
+ + {loading && ( +
+ Searching… +
+ )} + +
+ {tickets.map(t => ( +
+
+
+ {t.user?.name || "Guest"} +
+
+ {ticketLabel(t)} +
+
+ +
+ + +
+
+ ))} + + {!loading && tickets.length === 0 && ( +
+ No tickets found +
+ )} +
+ + {sendTarget && ( + setSendTarget(null)} + token={token} + registration={{ + id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId, + user: sendTarget.user, + }} + setError={() => {}} + setInfo={() => {}} + /> + )} +
+ ); +} + +function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) { + if (!open) return null; + + const safeOptions: any[] = options || []; + + const total = safeOptions.reduce((sum: number, o: any) => { + if ((o.variants || []).length > 0) { + return sum + (o.variants as any[]).reduce((vs: number, v: any) => + vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0); + } + return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o); + }, 0); + + const belowPaid = isEdit && total < totalPaid; + const belowIssued = isEdit && Object.entries(minQuantities).some( + ([key, minQty]: [string, any]) => minQty > 0 && (quantities[key] || 0) < minQty + ); + + const stepper = (key: string, delta: number) => + setQuantities((q: Record) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) })); + + return ( +
+
+ + {/* Header */} +
+
+ {isEdit ? "Edit Registration" : "Select Items"} +
+ {isEdit && totalPaid > 0 && ( +
+ Already paid: R {Number(totalPaid).toFixed(2)} — new total must be at least this amount. +
+ )} +
+ + {/* Scrollable Content */} +
+ {safeOptions.map((opt: any) => { + const hasVariants = (opt.variants || []).length > 0; + if (hasVariants) { + return ( +
+
+ {opt.name} + {opt.isMainTicket && • Main} +
+ {(opt.variants as any[]).map((v: any) => { + const unit = effectiveVariantUnit(opt, v); + const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price; + const key = `${opt.id}::${v.id}`; + const minQty = minQuantities[key] || 0; + return ( +
+
+
{v.name}
+
+ R {unit.toFixed(2)} + {unit < basePrice && basePrice > 0 && (early bird)} +
+ {minQty > 0 &&
{minQty} already issued
} +
+
+ +
{quantities[key] || 0}
+ +
+
+ ); + })} +
+ ); + } + const unit = effectiveOptionUnit(opt); + const minQty = minQuantities[opt.id] || 0; + return ( +
+
{opt.name}
+
+ R {unit.toFixed(2)} + {unit < opt.price && opt.price > 0 && (early bird, was R {opt.price.toFixed(2)})} + {opt.isMainTicket ? " • Main" : ""} +
+ {minQty > 0 &&
{minQty} already issued
} +
+ +
{quantities[opt.id] || 0}
+ +
+
+ ); + })} +
+ + {/* Footer */} +
+ {belowPaid && ( +
+ New total (R {total.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection. +
+ )} + {belowIssued && ( +
+ One or more items are below the quantity already issued as tickets. Please increase the selection. +
+ )} +
+
Total: R {total.toFixed(2)}
+
+ + +
+
+
+ +
+
+ ); +} + +function DonationModal({ open, onClose, user, token, eventId, onSuccess, setError }: any) { + const [amount, setAmount] = useState(""); + const [method, setMethod] = useState("cash"); + const [saving, setSaving] = useState(false); + + if (!open) return null; + + const saveDonation = async () => { + const amt = parseFloat(amount); + + if (!amt || amt <= 0) { + setError("Enter valid amount"); + return; + } + + try { + setSaving(true); + + await apiFetch("/api/payments", { + method: "POST", + authToken: token, + body: { + amount: amt, + method, + userId: user?.id || undefined, + eventId, + isDonation: true + } + }); + + onSuccess?.(); + + setAmount(""); + onClose(); + + } catch (e: any) { + setError(e?.message || "Donation failed"); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ +
+ Donation +
+ +
+ {user ? user.name : "Anonymous Donation"} +
+ + setAmount(e.target.value)} + autoFocus + /> + + + +
+ + + +
+
+
+ ); +} + +function SendTicketsModal({ open, onClose, token, registration, setError, setInfo }: any) { + const [channel, setChannel] = useState<"email" | "whatsapp" | "both">("email"); + const [phone, setPhone] = useState(""); + const [email, setEmail] = useState(""); + const [sending, setSending] = useState(false); + const [localError, setLocalError] = useState(""); + const [localInfo, setLocalInfo] = useState(""); + + useEffect(() => { + if (open && registration) { + setPhone(registration.user?.phoneNumber || ""); + setEmail(registration.user?.email?.endsWith("@guest.local") ? "" : registration.user?.email || ""); + setLocalError(""); + setLocalInfo(""); + } + }, [open, registration]); + + if (!open) return null; + + const send = async () => { + setLocalError(""); + if ((channel === "email" || channel === "both") && !email.trim()) { + setLocalError("Email address is required for email delivery."); + return; + } + if ((channel === "whatsapp" || channel === "both") && !phone.trim()) { + setLocalError("Phone number is required for WhatsApp delivery."); + return; + } + setSending(true); + try { + await apiFetch("/api/tickets/send-to", { + method: "POST", + authToken: token, + body: { + registrationId: registration.id, + channel, + overrideEmail: email.trim() || undefined, + overridePhone: phone.trim() || undefined, + }, + }); + setLocalInfo("Tickets sent successfully."); + setTimeout(() => { setLocalInfo(""); onClose(); }, 2000); + } catch (e: any) { + setLocalError(e?.message || "Failed to send tickets"); + } finally { + setSending(false); + } + }; + + return ( +
+
+
+
Send Tickets
+
+ {registration?.user?.name || "Guest"} +
+ +
+ +
+ {(["email", "whatsapp", "both"] as const).map((c) => ( + + ))} +
+
+ + {(channel === "email" || channel === "both") && ( +
+ + setEmail(e.target.value)} + /> +
+ )} + + {(channel === "whatsapp" || channel === "both") && ( +
+ + setPhone(e.target.value)} + /> +
+ )} + + {localError &&

{localError}

} + {localInfo &&

{localInfo}

} + +
+ + +
+
+
+
+ ); +} + +function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded, setError }: any) { + const [amount, setAmount] = useState(""); + const [method, setMethod] = useState("cash"); + const [reason, setReason] = useState(""); + const [saving, setSaving] = useState(false); + const [localError, setLocalError] = useState(""); + + useEffect(() => { + if (open) { setAmount(""); setReason(""); setLocalError(""); } + }, [open]); + + if (!open) return null; + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + const amt = parseFloat(amount); + if (!amt || amt <= 0) { setLocalError("Enter a valid amount."); return; } + if (amt > maxRefund) { setLocalError(`Cannot exceed total paid (R ${Number(maxRefund).toFixed(2)}).`); return; } + + setSaving(true); + setLocalError(""); + try { + await apiFetch("/api/payments/refund", { + method: "POST", + authToken: token, + body: { + userId: registration.userId, + registrationId: registration.id, + amount: amt, + method, + reason: reason || undefined, + } + }); + // Re-fetch registration to get updated status & payments + const updated = await apiFetch(`/api/registrations/${registration.id}`, { authToken: token }); + onRefunded(updated); + } catch (e: any) { + setLocalError(e?.message || "Refund failed."); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+
Issue Refund
+
+ {registration.user?.name || "Guest"} + · Total paid: R {Number(maxRefund).toFixed(2)} +
+ +
+ +
+ setAmount(e.target.value)} + autoFocus + /> + +
+
+ +
+ + +
+ +
+ + setReason(e.target.value)} + /> +
+ + {localError &&

{localError}

} + +
+ + +
+
+
+
+ ); +} + +function DoorRefundPanel({ token, eventId, setError, setInfo }: any) { + const [search, setSearch] = useState(""); + const [allRegs, setAllRegs] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedReg, setSelectedReg] = useState(null); + const [showRefund, setShowRefund] = useState(false); + + const load = async () => { + if (!token || !eventId) return; + try { + setLoading(true); + const regs = await apiFetch(`/api/registrations/event/${eventId}`, { authToken: token }); + setAllRegs(regs || []); + } catch { + setAllRegs([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, [token, eventId]); + + // Only show registrations that have at least one payment + const paidRegs = allRegs.filter(r => + (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0) > 0 + ); + const results = fuzzyFilterRegs(paidRegs, search); + + const handleRefunded = async (updated: any) => { + setShowRefund(false); + setSelectedReg(updated); + setInfo("Refund recorded"); + // Refresh list + const regs = await apiFetch(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null); + if (regs) setAllRegs(regs); + }; + + const maxRefund = selectedReg + ? (selectedReg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0) + : 0; + + return ( +
+
+
Refunds
+ +
+ + {!selectedReg ? ( + <> + setSearch(e.target.value)} + autoFocus + /> + +
+ {results.map(r => { + const paidValue = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0); + return ( +
+
+
{r.user?.name || "Guest"}
+
+ {r.user?.email && !r.user.email.endsWith("@guest.local") ? r.user.email : ""} + {r.user?.phoneNumber ? ` · ${r.user.phoneNumber}` : ""} +
+
+ Paid: R {paidValue.toFixed(2)} · {r.status} +
+
+ +
+ ); + })} + + {!loading && results.length === 0 && ( +
+ No paid registrations found for this event. +
+ )} +
+ + ) : ( +
+ + +
+
{selectedReg.user?.name || "Guest"}
+
+ {selectedReg.user?.email && !selectedReg.user.email.endsWith("@guest.local") ? selectedReg.user.email : ""} + {selectedReg.user?.phoneNumber ? ` · ${selectedReg.user.phoneNumber}` : ""} +
+
+
+
TOTAL
+
+ R {(selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => { + const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined + ? Number(o.priceSnapshot) + : (o.eventOption?.price || o.price || 0); + return s + price * (o.quantity || 0); + }, 0).toFixed(2)} +
+
+
+
PAID
+
R {maxRefund.toFixed(2)}
+
+
+ + {(selectedReg.payments || []).length > 0 && ( +
+
Payment history
+
+ {selectedReg.payments.map((p: any, i: number) => ( +
+ + {p.amount < 0 ? "Refund" : "Payment"} — {p.method || "—"} + {p.reason ? ` (${p.reason})` : ""} + + + R {Number(p.amount).toFixed(2)} + +
+ ))} +
+
+ )} +
+ + {maxRefund > 0 ? ( + + ) : ( +
+ Nothing to refund — net paid is R 0.00. +
+ )} +
+ )} + + setShowRefund(false)} + token={token} + registration={selectedReg} + maxRefund={maxRefund} + onRefunded={handleRefunded} + setError={setError} + /> +
+ ); +} + +function NewAttendeeModal({ open, onClose, seed, onConfirm }: { + open: boolean; + onClose: () => void; + seed: string; + onConfirm: (data: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => void; +}) { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email"); + const [err, setErr] = useState(""); + + // Pre-fill name from whatever was typed in search + useEffect(() => { + if (open) { + setName(seed || ""); + setEmail(""); + setPhone(""); + setNotifPref("email"); + setErr(""); + } + }, [open, seed]); + + if (!open) return null; + + const derivedPref = email.trim() && phone.trim() ? notifPref : phone.trim() ? "whatsapp" : "email"; + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { setErr("Name is required."); return; } + if (!email.trim() && !phone.trim()) { setErr("Provide at least an email or phone number."); return; } + onConfirm({ name: name.trim(), email: email.trim(), phone: phone.trim(), notifPref: derivedPref }); + }; + + return ( +
+
+
+
New Attendee
+ +
+ + setName(e.target.value)} + placeholder="Full name" + autoFocus + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="Email address" + /> +
+ +
+ + { + const v = e.target.value; + setPhone(v); + if (v.trim() && !email.trim()) setNotifPref("whatsapp"); + else if (!v.trim() && email.trim()) setNotifPref("email"); + }} + placeholder="+27…" + /> +
+ + {/* Preference selector — shown only when both channels are provided */} + {email.trim() && phone.trim() && ( +
+ +
+ {(["email", "whatsapp", "both"] as const).map((p) => ( + + ))} +
+
+ )} + +

At least one of email or cell number is required. If no email is provided, a guest account is created.

+ + {err &&

{err}

} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx b/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx new file mode 100644 index 0000000..53c2195 --- /dev/null +++ b/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx @@ -0,0 +1,1177 @@ +"use client"; + +import React, { Suspense, useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter, useSearchParams } from "next/navigation"; +import { apiFetch, fetchAllUsers } from "@/lib/api"; + +type Attendee = { id: string; name: string; email: string; pref: string }; + +export default function EmailAttendeesPage() { + return ( + Loading...
}> + + + ); +} + +function AttendeesCheckboxDropdown({ + attendees, + loading, + selectedIds, + onChange, + channel = "email", +}: { + attendees: { id: string; name: string; email?: string; pref: string }[]; + loading: boolean; + selectedIds: string[]; + onChange: (ids: string[]) => void; + channel?: "email" | "whatsapp"; +}) { + const [open, setOpen] = useState(false); + + const allIds = useMemo(() => attendees.map(a => a.id), [attendees]); + const allSelected = selectedIds.length > 0 && selectedIds.length === allIds.length; + const noneSelected = selectedIds.length === 0; + + const prefMatch = (pref: string) => + channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || pref === "both"; + + const prefLabel = (pref: string) => { + if (pref === "both") return "both"; + if (pref === "whatsapp") return "WA"; + if (pref === "email") return "email"; + return pref || "email"; + }; + + const mismatched = selectedIds.filter(id => { + const a = attendees.find(x => x.id === id); + return a && !prefMatch(a.pref); + }); + + const toggleAll = (checked: boolean) => onChange(checked ? allIds : []); + const toggleId = (id: string) => { + if (selectedIds.includes(id)) onChange(selectedIds.filter(x => x !== id)); + else onChange([...selectedIds, id]); + }; + + const summary = loading + ? "Loading attendees…" + : attendees.length === 0 + ? "No attendees" + : allSelected + ? `All attendees (${attendees.length})` + : noneSelected + ? "None selected" + : `${selectedIds.length} selected`; + + return ( +
+ + {open && ( +
+
+ +
+ + +
+
+ {loading ? ( +
Loading…
+ ) : attendees.length === 0 ? ( +
No attendees found
+ ) : ( +
    + {attendees.map(a => { + const checked = selectedIds.includes(a.id); + const match = prefMatch(a.pref); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+
+ )} +
+ ); +} + +function PrefWarning({ attendees, selectedIds, channel }: { attendees: { id: string; pref: string }[]; selectedIds: string[]; channel: "email" | "whatsapp" }) { + const prefMatch = (pref: string) => + channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || pref === "both"; + + const mismatched = useMemo( + () => selectedIds.filter(id => { + const a = attendees.find(x => x.id === id); + return a && !prefMatch(a.pref); + }), + [attendees, selectedIds, channel] + ); + + if (mismatched.length === 0) return null; + return ( +
+ {mismatched.length} selected attendee(s) have a notification preference that doesn't include{" "} + {channel === "email" ? "email" : "WhatsApp"}. They will still receive the message, but it may not be their preferred channel. + {" "}Use the dropdown to filter by preference. +
+ ); +} + +function EmailAttendeesPageInner() { + const { user, loading, token } = useAuth(); + const router = useRouter(); + const search = useSearchParams(); + const preselectEventId = search?.get("eventId") || ""; + + const canView = useMemo(() => { + const role = user?.role; + return role === "admin" || role === "supervisor"; + }, [user]); + + useEffect(() => { + if (loading) return; + if (!user) router.replace("/login"); + }, [user, loading, router]); + + // Load events for selection + const [loadingEvents, setLoadingEvents] = useState(false); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + + // Tabs: attendees (current), automations (coming soon), broadcasts + const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees'); + + const isAdmin = user?.role === "admin"; + + const [allEvents, setAllEvents] = useState([]); + const [evIncludePast, setEvIncludePast] = useState(false); + + const events = useMemo(() => { + const now = Date.now(); + if (evIncludePast) return allEvents; + return allEvents.filter(ev => { + const t = new Date(ev.endDate).getTime(); + return !isNaN(t) && t > now; + }); + }, [allEvents, evIncludePast]); + + const loadEvents = async () => { + try { + setLoadingEvents(true); + const evs = await apiFetch("/api/events/all?includePast=true", { authToken: token || undefined }); + const sorted = (evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); + setAllEvents(sorted); + } catch (e: any) { + setError(e?.message || "Failed to load events"); + } finally { + setLoadingEvents(false); + } + }; + + useEffect(() => { loadEvents(); }, [user, token]); + + // Load users for broadcasts + useEffect(() => { + const run = async () => { + try { + if (!token) return; + setLoadingUsers(true); + const allUsers = await fetchAllUsers(token); + const mapped = allUsers + .filter((u: any) => u.isActive !== false && !u.email?.endsWith('@guest.local')) + .map((u: any) => ({ id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' })) + .sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })); + setUsers(mapped); + } catch (e) { + // ignore for now + } finally { + setLoadingUsers(false); + } + }; + run(); + }, [token]); + + // Form state + const [eventId, setEventId] = useState(preselectEventId); + useEffect(() => { + if (preselectEventId) setEventId(preselectEventId); + }, [preselectEventId]); + + const [templateKey, setTemplateKey] = useState<'custom'|'payment_reminder'|'event_reminder'|'tickets'>('custom'); + const [showInfo, setShowInfo] = useState(false); + + // Broadcasts state + const [users, setUsers] = useState<{id:string;name:string;email:string;pref:string}[]>([]); + const [loadingUsers, setLoadingUsers] = useState(false); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [broadcastEventId, setBroadcastEventId] = useState(""); + const [broadcastSubject, setBroadcastSubject] = useState(""); + const [broadcastBody, setBroadcastBody] = useState(""); + const [broadcastEmails, setBroadcastEmails] = useState(""); + const [broadcastPreviewCount, setBroadcastPreviewCount] = useState(null); + const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{email:string;name?:string}[]|null>(null); + const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState(""); + const resetBroadcastForm = () => { + setSelectedUserIds([]); + setBroadcastEventId(""); + setBroadcastSubject(""); + setBroadcastBody(""); + setBroadcastEmails(""); + setBroadcastPreviewCount(null); + setBroadcastPreviewSample(null); + setBroadcastScheduledAtLocal(""); + }; + + // Scheduled jobs state + type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; hasHtml?: boolean; hasText?: boolean }; + const [scheduled, setScheduled] = useState([]); + const [loadingScheduled, setLoadingScheduled] = useState(false); + const [editing, setEditing] = useState(null); + const [editSubject, setEditSubject] = useState(''); + const [editBody, setEditBody] = useState(''); + const [editWhen, setEditWhen] = useState(''); + const [savingEdit, setSavingEdit] = useState(false); + + const loadScheduled = async () => { + try { + if (!token) return; + setLoadingScheduled(true); + const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails`, { authToken: token }); + setScheduled(Array.isArray(res?.jobs) ? res.jobs : []); + } catch (e) { + // ignore here; surfaces via UI when tab open + } finally { + setLoadingScheduled(false); + } + }; + + useEffect(() => { if (tab === 'scheduled') loadScheduled(); }, [tab, token]); + + const openEdit = (job: ScheduledJob) => { + setEditing(job); + setEditSubject(job.subject || ''); + setEditBody(''); // body not included in list; will let user set a new one if needed + try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(''); } + }; + + const saveEdit = async () => { + if (!editing) return; + try { + setSavingEdit(true); + if (!token) { setError('Not authenticated'); return; } + const body: any = {}; + if (editWhen) body.scheduledAt = new Date(editWhen).toISOString(); + if (editSubject.trim().length || editBody.trim().length) { + body.subject = editSubject; + if (editBody.trim()) { + if (editBody.trim().startsWith('<')) body.html = editBody; else body.text = editBody; + } else { + // if clearing body, explicitly set text to empty to override + body.text = ''; + body.html = ''; + } + } + await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: 'PATCH', authToken: token, body }); + setInfo('Scheduled email updated.'); + setEditing(null); + loadScheduled(); + } catch (e:any) { + setError(e?.message || 'Failed to update scheduled email'); + } finally { + setSavingEdit(false); + } + }; + + const removeJob = async (job: ScheduledJob) => { + try { + if (!token) { setError('Not authenticated'); return; } + await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: 'DELETE', authToken: token }); + setInfo('Scheduled email removed.'); + loadScheduled(); + } catch (e:any) { + setError(e?.message || 'Failed to remove scheduled email'); + } + }; + + const [subject, setSubject] = useState(""); + const [body, setBody] = useState(""); + const [subjectDirty, setSubjectDirty] = useState(false); + const [bodyDirty, setBodyDirty] = useState(false); + const [status, setStatus] = useState<'any'|'paid'|'unpaid'|'partial_paid'|'cancelled'>("any"); + // Attendees list and selection + const [attendees, setAttendees] = useState([]); + const [selectedAttendeeIds, setSelectedAttendeeIds] = useState([]); + const [loadingAttendees, setLoadingAttendees] = useState(false); + + // Event helper to prefill templates + const currentEvent = useMemo(() => (events || []).find(e => e.id === eventId), [events, eventId]); + + useEffect(() => { + const title = currentEvent?.title || 'the event'; + if (templateKey === 'payment_reminder') { + if (!subjectDirty) setSubject(`Payment reminder: ${title}`); + if (!bodyDirty) setBody(`Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`); + } else if (templateKey === 'event_reminder') { + if (!subjectDirty) setSubject(`Reminder: ${title}`); + if (!bodyDirty) setBody(`Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`); + } else if (templateKey === 'tickets') { + // Tickets template does not require subject/body + setSubjectDirty(false); + setBodyDirty(false); + setSubject(''); + setBody(''); + } else { + // custom: do not overwrite user content unless not dirty and we have event change + if (!subjectDirty && subject) setSubject(subject); // keep as-is + if (!bodyDirty && body) setBody(body); + } + // When event changes, refresh defaults if not dirty + }, [templateKey, currentEvent, subjectDirty, bodyDirty]); + + const [previewCount, setPreviewCount] = useState(null); + const [previewSample, setPreviewSample] = useState<{email:string;name?:string}[] | null>(null); + const [sending, setSending] = useState(false); + const [scheduledAtLocal, setScheduledAtLocal] = useState(""); + + const mismatchedAttendees = useMemo( + () => selectedAttendeeIds.filter(id => { + const a = attendees.find(x => x.id === id); + return a && a.pref !== 'email' && a.pref !== 'both'; + }), + [attendees, selectedAttendeeIds] + ); + + // Load attendees when event changes + useEffect(() => { + const run = async () => { + try { + setLoadingAttendees(true); + setAttendees([]); + setSelectedAttendeeIds([]); + if (!eventId || !token) return; + const regs = await apiFetch(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token }); + const uniq = new Map(); + (regs || []).forEach((r: any) => { + const u = r?.user; + if (u?.id && u?.email && !u.email.endsWith('@guest.local') && u.isActive !== false) { + uniq.set(u.id, { id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' }); + } + }); + const list = Array.from(uniq.values()).sort((a,b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })); + setAttendees(list); + // Default: select those with email/both preference; fall back to all + const matching = list.filter(a => a.pref === 'email' || a.pref === 'both').map(a => a.id); + setSelectedAttendeeIds(matching.length > 0 ? matching : list.map(a => a.id)); + } catch (e) { + // ignore + } finally { + setLoadingAttendees(false); + } + }; + run(); + }, [eventId, token]); + + const onPreview = async () => { + try { + setError(null); setInfo(null); setPreviewCount(null); setPreviewSample(null); + if (!token) { setError("Not authenticated"); return; } + if (!eventId) { setError("Please select an event"); return; } + if (templateKey !== 'tickets') { + if (!subject.trim()) { setError("Subject is required"); return; } + if (!body.trim()) { setError("Message is required"); return; } + } + const payload: any = { + subject: subject || "(no subject)", + filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined }, + dryRun: true, + template: templateKey, + }; + if (templateKey !== 'tickets') { + if (body.trim().startsWith('<')) payload.html = body; else payload.text = body; + } + const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload }); + setPreviewCount(res?.matched ?? 0); + setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null); + setInfo(`Matched ${res?.matched ?? 0} recipient(s).`); + } catch (e: any) { + setError(e?.message || 'Failed to preview recipients'); + } + }; + + const resetAttendeesForm = () => { + setTemplateKey('custom'); + setSubject(''); setBody(''); + setSubjectDirty(false); setBodyDirty(false); + setStatus('any'); + setScheduledAtLocal(''); + setPreviewCount(null); setPreviewSample(null); + // Keep event selection but reselect email/both attendees + const matching = attendees.filter(a => a.pref === 'email' || a.pref === 'both').map(a => a.id); + setSelectedAttendeeIds(matching.length > 0 ? matching : attendees.map(a => a.id)); + }; + + const onSend = async () => { + try { + setError(null); setInfo(null); + if (!token) { setError("Not authenticated"); return; } + if (!eventId) { setError("Please select an event"); return; } + if (templateKey !== 'tickets') { + if (!subject.trim()) { setError("Subject is required"); return; } + if (!body.trim()) { setError("Message is required"); return; } + } + setSending(true); + const payload: any = { + subject, + filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined }, + template: templateKey, + }; + if (templateKey !== 'tickets') { + if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n'); + } + const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload }); + const sent = res?.sent ?? 0; const matched = res?.matched ?? 0; + setInfo(`Sent ${sent} out of ${matched} recipient(s).`); + // Reset form to default state + resetAttendeesForm(); + } catch (e: any) { + setError(e?.message || 'Failed to send emails'); + } finally { + setSending(false); + } + }; + + return ( +
+
+

Email Attendees

+
+ +
+
+ + {!canView && ( +
+ You need supervisor or admin access to use this page. +
+ )} + + {error &&
{error}
} + {info &&
{info}
} + + {/* Tabs like on Payments page */} +
+ + + + +
+ + {tab === 'attendees' && ( +
+
+
+ +
+ + {loadingEvents && Loading…} +
+ +
+ +
+
+
+ + +
+ +
+
+
Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'}
+
+
+ + {showInfo && ( +
+
setShowInfo(false)} /> +
+
+
+

Dynamic parameters

+ +
+
+

You can personalize your subject and message using these placeholders. They will be replaced per recipient when sending.

+
    +
  • {'{{name}}'} — attendee’s name.
  • +
  • {'{{event.title}}'} — the event title.
  • +
  • {'{{event.start}}'} — the event start date/time (local).
  • +
  • {'{{balance}}'} — outstanding amount across the attendee’s registrations for the selected event.
  • +
+

Example: Hi {'{{name}}'}, your balance is {'{{balance}}'}.

+

To add new placeholders, extend the replacement logic in eventController.emailEventAttendees (replacePlaceholders function) and update this help.

+
+
+
+
+ )} + +
+
+ + { setSubject(e.target.value); setSubjectDirty(true); }} placeholder="Subject" /> +
+
+ + +
+
+ + {templateKey !== 'tickets' ? ( +
+ + +
+
+ + setEditWhen(e.target.value)} /> +
+
+ + +
+
+
+
+
+ )} + + )} + + ); +} + + +function toLocalInputValue(d: Date) { + const pad = (n: number) => String(n).padStart(2, '0'); + const y = d.getFullYear(); + const m = pad(d.getMonth() + 1); + const day = pad(d.getDate()); + const hh = pad(d.getHours()); + const mm = pad(d.getMinutes()); + return `${y}-${m}-${day}T${hh}:${mm}`; +} + +function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) { + const [autoEventId, setAutoEventId] = React.useState(''); + const currentEvent = React.useMemo(() => (events || []).find((e:any) => e.id === autoEventId), [events, autoEventId]); + + // Defaults builders + const preWeekDefault = React.useMemo(() => ({ + subject: `1 Week to Go: {{event.title}}!`, + body: `Hi {{name}}\n\nJust a friendly reminder that {{event.title}} is one week away.\n\nVenue: [add venue]\nTime: {{event.start}}\nParking: [add parking info]\nPacking list: [add items if needed]\n\nMore details: {{event.link}}`, + }), []); + const finalDefault = React.useMemo(() => ({ + subject: `We’ll See You Tomorrow at {{event.title}}!`, + body: `Hi {{name}}\n\nFinal reminder for {{event.title}}.\nArrival instructions: [add arrival info]\nPlease have your QR code/ticket ready at entry.\n\nEvent details: {{event.link}}`, + }), []); + const thanksDefault = React.useMemo(() => ({ + subject: `Thanks for Joining {{event.title}}!`, + body: `Hi {{name}}\n\nThank you for joining us at {{event.title}}!\nHighlights: [add highlights]\nPhotos/recordings: [add links]\nSponsor shoutouts: [add sponsors]\n\nSee you next time!`, + }), []); + const promoDefault = React.useMemo(() => ({ + subject: `Don’t Miss Our Next Event: {{promo.title}}`, + body: `Hi {{name}}\n\nWe’d love to see you at our next event: {{promo.title}}.\nFind out more and register here: {{promo.link}}`, + }), []); + + // Enable toggles + const [enablePre, setEnablePre] = React.useState(true); + const [enableFinal, setEnableFinal] = React.useState(true); + const [enableThanks, setEnableThanks] = React.useState(true); + const [enablePromo, setEnablePromo] = React.useState(false); + + // Subjects/Bodies + const [preSubject, setPreSubject] = React.useState(preWeekDefault.subject); + const [preBody, setPreBody] = React.useState(preWeekDefault.body); + const [finalSubject, setFinalSubject] = React.useState(finalDefault.subject); + const [finalBody, setFinalBody] = React.useState(finalDefault.body); + const [thanksSubject, setThanksSubject] = React.useState(thanksDefault.subject); + const [thanksBody, setThanksBody] = React.useState(thanksDefault.body); + const [promoSubject, setPromoSubject] = React.useState(promoDefault.subject); + const [promoBody, setPromoBody] = React.useState(promoDefault.body); + + // Timing + const [finalHours, setFinalHours] = React.useState<'24'|'48'>('24'); + const [preWhen, setPreWhen] = React.useState(''); + const [finalWhen, setFinalWhen] = React.useState(''); + const [thanksWhen, setThanksWhen] = React.useState(''); + const [promoWhen, setPromoWhen] = React.useState(''); + const [promoEventId, setPromoEventId] = React.useState(''); + + // Compute defaults when event changes or finalHours change + React.useEffect(() => { + if (!currentEvent) { setPreWhen(''); setFinalWhen(''); setThanksWhen(''); setPromoWhen(''); return; } + try { + const start = new Date(currentEvent.startDate); + const end = new Date(currentEvent.endDate || currentEvent.startDate); + // Pre-event: 7 days before start at 09:00 + const pre = new Date(start); + pre.setDate(pre.getDate() - 7); + pre.setHours(9, 0, 0, 0); + setPreWhen(toLocalInputValue(pre)); + // Final reminder: hours before start + const fin = new Date(start); + fin.setHours(fin.getHours() - (finalHours === '48' ? 48 : 24)); + setFinalWhen(toLocalInputValue(fin)); + // Thanks: day after end at 09:00 + const ty = new Date(end); + ty.setDate(ty.getDate() + 1); + ty.setHours(9, 0, 0, 0); + setThanksWhen(toLocalInputValue(ty)); + // Promo: 3 days after end at 09:00 + const pr = new Date(end); + pr.setDate(pr.getDate() + 3); + pr.setHours(9, 0, 0, 0); + setPromoWhen(toLocalInputValue(pr)); + // Prefill subjects again using event title (user can edit afterward) + const title = currentEvent.title || 'the event'; + setPreSubject(`1 Week to Go: ${title}!`); + setFinalSubject(`We’ll See You Tomorrow at ${title}!`); + setThanksSubject(`Thanks for Joining ${title}!`); + setPromoSubject(`Don’t Miss Our Next Event: {{promo.title}`); + } catch {} + }, [currentEvent, finalHours]); + + const onSchedule = async () => { + try { + onError(null as any); onInfo(null as any); + if (!token) { onError('Not authenticated'); return; } + if (!autoEventId) { onError('Please select an event'); return; } + const jobs: any[] = []; + if (enablePre && preSubject.trim() && preBody.trim() && preWhen) { + const payload: any = { subject: preSubject, scheduledAt: new Date(preWhen).toISOString() }; + if (preBody.trim().startsWith('<')) payload.html = preBody; else payload.text = preBody; + jobs.push(payload); + } + if (enableFinal && finalSubject.trim() && finalBody.trim() && finalWhen) { + const payload: any = { subject: finalSubject, scheduledAt: new Date(finalWhen).toISOString() }; + if (finalBody.trim().startsWith('<')) payload.html = finalBody; else payload.text = finalBody; + jobs.push(payload); + } + if (enableThanks && thanksSubject.trim() && thanksBody.trim() && thanksWhen) { + const payload: any = { subject: thanksSubject, scheduledAt: new Date(thanksWhen).toISOString() }; + if (thanksBody.trim().startsWith('<')) payload.html = thanksBody; else payload.text = thanksBody; + jobs.push(payload); + } + if (enablePromo && promoSubject.trim() && promoBody.trim() && promoWhen) { + const payload: any = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined }; + if (promoBody.trim().startsWith('<')) payload.html = promoBody; else payload.text = promoBody; + jobs.push(payload); + } + if (jobs.length === 0) { onError('Please enable at least one automation and fill in details'); return; } + const res = await apiFetch(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } }); + onInfo(res?.message || `Scheduled ${jobs.length} automation(s).`); + // Reset to defaults (keep event selection) + setEnablePre(true); setEnableFinal(true); setEnableThanks(true); setEnablePromo(false); + setPreSubject(preWeekDefault.subject); setPreBody(preWeekDefault.body); + setFinalSubject(finalDefault.subject); setFinalBody(finalDefault.body); setFinalHours('24'); + setThanksSubject(thanksDefault.subject); setThanksBody(thanksDefault.body); + setPromoSubject(promoDefault.subject); setPromoBody(promoDefault.body); setPromoEventId(''); + } catch (e:any) { + onError(e?.message || 'Failed to schedule automations'); + } + }; + + return ( +
+
+ + +
+ +
+
+ Pre-Event Reminder (1 week before) + +
+
+ + setPreSubject(e.target.value)} placeholder="1 Week to Go: {{event.title}}!" /> +
+
+ + setPreWhen(e.target.value)} /> +
+
+ +