Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
+36
@@ -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/
|
||||||
@@ -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:<backend-port>
|
||||||
|
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.
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
# Hope Events — API Documentation
|
||||||
|
|
||||||
|
Full interactive docs (with request/response examples) are available at `GET /docs?token=<admin-jwt>` on the running backend.
|
||||||
|
|
||||||
|
All authenticated requests require:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
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: "<uuid>"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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=<admin-jwt>` on the running backend.*
|
||||||
@@ -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=<admin-jwt>` for full API documentation (admin only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication & Roles
|
||||||
|
|
||||||
|
All protected routes require a `Bearer` token in the `Authorization` header:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
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=<jwt>` 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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"message": "",
|
||||||
|
"type": "info",
|
||||||
|
"liveFrom": null,
|
||||||
|
"liveTill": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"jobs": []
|
||||||
|
}
|
||||||
Generated
+2260
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "failedAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "lockUntil" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable: add tokenVersion for JWT revocation
|
||||||
|
ALTER TABLE "User" ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -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';
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+21
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
@@ -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])
|
||||||
|
// }
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
module.exports = prisma;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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, '"')
|
||||||
|
.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 = `<a href="${eventLink}">${eventLink}</a>`;
|
||||||
|
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 <email@example.com>
|
||||||
|
// - "Name" <email@example.com>
|
||||||
|
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 };
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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: `<p>This is a test email sent from the <strong>Hope Events</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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(`
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">🎟️ Your tickets are here!</p>
|
||||||
|
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
|
||||||
|
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Your tickets for <strong>${eventTitle}</strong>${eventDate ? ` on <strong>${eventDate}</strong>` : ''} are attached.</p>
|
||||||
|
<p style="font-size:13px;color:#64748b;font-family:${ff}">Questions? Visit <a href="${orgUrl}" style="color:#2563eb">${orgUrl}</a>.</p>
|
||||||
|
`, { 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
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <phone> 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 };
|
||||||
@@ -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: `<p>The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
|
||||||
|
}).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,
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||||
|
<title>${org.name}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#f1f5f9;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">
|
||||||
|
${preheader ? `<div style="display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all">${preheader}‌ ‌ ‌ ‌ </div>` : ''}
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f1f5f9">
|
||||||
|
<tr><td align="center" style="padding:40px 16px">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr><td style="background:linear-gradient(135deg,${org.headerColor} 0%,#2d5287 100%);border-radius:12px 12px 0 0;padding:40px 48px;text-align:center">
|
||||||
|
<h1 style="color:#ffffff;font-size:28px;font-weight:800;margin:0;letter-spacing:-0.5px;font-family:${ff};text-shadow:0 1px 3px rgba(0,0,0,0.3)">${org.name}</h1>
|
||||||
|
<p style="color:#ffffff;font-size:14px;font-weight:500;margin:8px 0 0 0;font-family:${ff};letter-spacing:0.3px;opacity:0.9">${org.tagline}</p>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr><td style="background:#ffffff;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;padding:48px 48px 40px 48px">
|
||||||
|
<div style="font-family:${ff};color:#1e293b;font-size:15px;line-height:1.75">
|
||||||
|
${body}
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr><td style="background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;padding:24px 48px;text-align:center">
|
||||||
|
<p style="color:#94a3b8;font-size:12px;margin:0 0 4px 0;font-family:${ff}">
|
||||||
|
${org.name} •
|
||||||
|
<a href="mailto:${org.email}" style="color:#94a3b8;text-decoration:underline">${org.email}</a> •
|
||||||
|
<a href="${org.url}" style="color:#94a3b8;text-decoration:underline">${org.url}</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#cbd5e1;font-size:11px;margin:6px 0 0 0;font-family:${ff}">
|
||||||
|
This email was sent because you have an account or registration with ${org.name}.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a prominent CTA button. */
|
||||||
|
function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
|
||||||
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto">
|
||||||
|
<tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px">
|
||||||
|
<a href="${url}" target="_blank"
|
||||||
|
style="display:inline-block;padding:14px 36px;font-size:15px;font-weight:700;color:${fg};text-decoration:none;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;letter-spacing:0.2px;mso-hide:none"
|
||||||
|
>${label}</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a fallback link below a CTA button. */
|
||||||
|
function fallbackLink(url) {
|
||||||
|
return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all">
|
||||||
|
Or copy this link: <a href="${url}" style="color:#2563eb">${url}</a>
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal rule. */
|
||||||
|
function divider() {
|
||||||
|
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 `<div style="background:${s.bg};border-left:4px solid ${s.border};border-radius:0 6px 6px 0;padding:16px 20px;margin:24px 0;color:${s.color};font-size:14px;line-height:1.6">
|
||||||
|
${content}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numbered payment option row. */
|
||||||
|
function paymentOption(num, title, detail) {
|
||||||
|
return `<tr>
|
||||||
|
<td style="padding:14px 16px 14px 0;vertical-align:top;width:28px">
|
||||||
|
<div style="width:26px;height:26px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top">
|
||||||
|
<p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p>
|
||||||
|
<div style="font-size:13px;color:#64748b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;line-height:1.5">${detail}</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Builder functions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildPasswordResetEmail({ name, resetUrl }) {
|
||||||
|
const org = getOrg();
|
||||||
|
const preheader = `Reset your ${org.name} password. This link expires in 1 hour.`;
|
||||||
|
const body = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Password reset request</p>
|
||||||
|
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">We received a request to reset your password.</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 24px 0;color:#374151">Click the button below to choose a new password. This link will expire in <strong>1 hour</strong>.</p>
|
||||||
|
|
||||||
|
${ctaButton('Reset my password', resetUrl)}
|
||||||
|
${fallbackLink(resetUrl)}
|
||||||
|
|
||||||
|
${divider()}
|
||||||
|
<p style="margin:0;font-size:13px;color:#94a3b8">
|
||||||
|
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 <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Password changed</p>
|
||||||
|
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Security notification for your account</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 24px 0;color:#374151">
|
||||||
|
Your <strong>${org.name}</strong> account password was successfully changed
|
||||||
|
${when ? `on <strong>${whenText}</strong>` : 'recently'}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${callout(`<strong>This wasn't you?</strong><br/>
|
||||||
|
If you did not make this change, your account may be compromised. Contact us immediately at
|
||||||
|
<a href="mailto:${contact}" style="color:#991b1b;font-weight:600">${contact}</a>
|
||||||
|
and reset your password right away.`, 'danger')}
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">
|
||||||
|
If you made this change, no further action is needed. This is an automated security notification.
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">New login detected</p>
|
||||||
|
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">A new session was opened on your account</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<td style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;width:36%;font-family:${ff}">Detail</td>
|
||||||
|
<td style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Value</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Time</td>
|
||||||
|
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;font-family:${ff}">${when}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Location</td>
|
||||||
|
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;font-family:${ff}">${location}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Device</td>
|
||||||
|
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;word-break:break-all;font-family:${ff}">${userAgent}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${callout(`<strong>Not you?</strong> If you don't recognise this login, change your password immediately and contact us at <a href="mailto:${org.email}" style="color:#991b1b">${org.email}</a>.`, 'danger')}
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">If this was you, no action is needed.</p>`;
|
||||||
|
|
||||||
|
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()}
|
||||||
|
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 16px 0">Upcoming events</p>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
${events.map(e => `<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9">
|
||||||
|
<p style="margin:0 0 2px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:${ff}">${e.title}</p>
|
||||||
|
<p style="margin:0;font-size:13px;color:#64748b;font-family:${ff}">${new Date(e.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</p>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</table>
|
||||||
|
${ctaButton('Browse all events', org.url + '/events')}`
|
||||||
|
: `<p style="color:#64748b;font-size:14px;margin:0">Keep an eye on our website — new events are added regularly!</p>
|
||||||
|
${ctaButton('View events', org.url + '/events')}`;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Welcome to ${org.name}!</p>
|
||||||
|
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Your account has been created</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 0 0;color:#374151">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${eventsBlock}
|
||||||
|
|
||||||
|
${divider()}
|
||||||
|
<p style="margin:0;font-size:13px;color:#94a3b8">We look forward to seeing you at our events!</p>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Activate your account</p>
|
||||||
|
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">One step to access your events and tickets</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 24px 0;color:#374151">
|
||||||
|
Your ${org.name} account is ready, but needs to be activated. Click below to set your password and access your registrations, tickets, and more.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${ctaButton('Activate my account', activationUrl, { bg: '#059669' })}
|
||||||
|
${fallbackLink(activationUrl)}
|
||||||
|
|
||||||
|
${callout('This activation link expires in <strong>24 hours</strong>.', 'warning')}
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">
|
||||||
|
If you didn't expect this email, you can safely ignore it.
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Account closed</p>
|
||||||
|
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Confirmation of account closure</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 16px 0;color:#374151">
|
||||||
|
This email confirms that your <strong>${org.name}</strong> account has been successfully closed.
|
||||||
|
</p>
|
||||||
|
${dataDeleted
|
||||||
|
? `<p style="margin:0 0 16px 0;color:#374151">All personal data associated with your account has been permanently deleted as requested.</p>`
|
||||||
|
: `<p style="margin:0 0 16px 0;color:#374151">Your account has been deactivated. If you would also like your personal data permanently deleted, please contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.</p>`
|
||||||
|
}
|
||||||
|
<p style="margin:0 0 0 0;color:#374151">
|
||||||
|
If you did not request this, please contact us immediately at
|
||||||
|
<a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -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:<iv>:<authTag>:<ciphertext>" 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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 `<tr>
|
||||||
|
<td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td>
|
||||||
|
<td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td>
|
||||||
|
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(price * qty)}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
|
||||||
|
|
||||||
|
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 4px 0">
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<th style="padding:10px 16px 10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:left;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Item</th>
|
||||||
|
<th style="padding:10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:center;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Qty</th>
|
||||||
|
<th style="padding:10px 0 10px 16px;font-size:12px;font-weight:700;color:#64748b;text-align:right;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Amount</th>
|
||||||
|
</tr>
|
||||||
|
${rows.join('')}
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a financial summary line. */
|
||||||
|
function financialSummary(totalDue, totalPaid, balance) {
|
||||||
|
const ff2 = ff;
|
||||||
|
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0 0 0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0;font-size:13px;color:#64748b;font-family:${ff2}">Total due</td>
|
||||||
|
<td style="padding:6px 0;font-size:13px;color:#374151;text-align:right;font-weight:500;font-family:${ff2}">${fmtAmount(totalDue)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0;font-size:13px;color:#64748b;font-family:${ff2}">Amount paid</td>
|
||||||
|
<td style="padding:6px 0;font-size:13px;color:#374151;text-align:right;font-weight:500;font-family:${ff2}">${fmtAmount(totalPaid)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-top:2px solid #e2e8f0">
|
||||||
|
<td style="padding:10px 0 6px 0;font-size:15px;font-weight:800;color:#${balance <= 0 ? '059669' : '1e293b'};font-family:${ff2}">${balance <= 0 ? 'Fully paid' : 'Balance due'}</td>
|
||||||
|
<td style="padding:10px 0 6px 0;font-size:15px;font-weight:800;color:#${balance <= 0 ? '059669' : '1e293b'};text-align:right;font-family:${ff2}">${fmtAmount(balance)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()}
|
||||||
|
<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 6px 0;font-family:${ff}">Manage your registration online</p>
|
||||||
|
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">
|
||||||
|
Log in to your account to view your registration, make payments, and download your tickets.
|
||||||
|
</p>
|
||||||
|
${ctaButton('Log in to your account', siteUrl + '/login', { bg: '#0f172a' })}`;
|
||||||
|
}
|
||||||
|
return `${divider()}
|
||||||
|
<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 6px 0;font-family:${ff}">Create your account</p>
|
||||||
|
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">
|
||||||
|
Create a free account to manage your registrations, make payments online, and access your tickets — all in one place.
|
||||||
|
</p>
|
||||||
|
${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(
|
||||||
|
`<strong style="font-size:15px">🎟️ You\'re all set!</strong><br/>
|
||||||
|
<span style="font-size:13px">No payment required. Your tickets have been sent in a separate email.</span>`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (balance <= 0 && formRequired) {
|
||||||
|
return callout(
|
||||||
|
`<strong>One more step — attendee form required</strong><br/>
|
||||||
|
<span style="font-size:13px">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.</span>`,
|
||||||
|
'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)',
|
||||||
|
`<a href="${yocoLink}" style="color:#2563eb;font-weight:600;text-decoration:underline">${yocoLink}</a><br/>
|
||||||
|
<span style="color:#94a3b8;font-style:italic">Already paid? You can safely ignore this option.</span>`
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push(paymentOption(
|
||||||
|
optNum++,
|
||||||
|
`Pay via our website`,
|
||||||
|
`Visit <a href="${siteUrl}" style="color:#2563eb">${siteUrl}</a> to pay online.${
|
||||||
|
isUserActive === false
|
||||||
|
? `<br/><span style="color:#64748b">You'll need to create a free account to pay online.</span>`
|
||||||
|
: isUserActive === true
|
||||||
|
? `<br/><span style="color:#64748b">Log in to access your registration and pay.</span>`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
));
|
||||||
|
|
||||||
|
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 `<p style="font-size:16px;font-weight:700;color:#0f172a;margin:32px 0 8px 0;font-family:${ff}">How to pay</p>
|
||||||
|
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">Choose any of the following payment methods:</p>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">${options.join('')}</table>
|
||||||
|
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">Your tickets will be emailed once your payment is confirmed.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 <strong>${eventTitle}</strong>.`
|
||||||
|
: `Your registration for <strong>${eventTitle}</strong> 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 = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${heading}</p>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">${isNew ? 'Your spot is reserved' : 'Changes saved'}</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
|
||||||
|
|
||||||
|
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||||
|
${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 <strong>${eventTitle}</strong>.`
|
||||||
|
: `Your registration for <strong>${eventTitle}</strong> 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 = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${heading}</p>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">${isNew ? 'Your spot is reserved' : 'Changes saved'}</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
|
||||||
|
|
||||||
|
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||||
|
${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 =>
|
||||||
|
`<tr>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0">Registration ${verb}</p>
|
||||||
|
<p style="font-size:13px;color:#64748b;margin:0 0 28px 0">${org.name} — internal notification</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<td colspan="2" style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Registrant details</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;width:30%;font-family:${ff};border-top:1px solid #f1f5f9">Name</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user?.name || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Email</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user?.email || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
${reg.user?.phoneNumber ? `<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Phone</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user.phoneNumber}</td>
|
||||||
|
</tr>` : ''}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Event</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${eventTitle}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Status</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.status || 'pending'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Reg ID</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${reg.id}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${itemRows ? `<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 8px 0;font-family:${ff}">Items</p>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 16px 0">
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Option</th>
|
||||||
|
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:center;font-family:${ff}">Qty</th>
|
||||||
|
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:right;font-family:${ff}">Amount</th>
|
||||||
|
</tr>${itemRows}
|
||||||
|
</table>` : ''}
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0 0 0">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px;color:#64748b;padding:4px 0;font-family:${ff}">Total due</td>
|
||||||
|
<td style="font-size:13px;color:#374151;text-align:right;font-weight:500;padding:4px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px;color:#64748b;padding:4px 0;font-family:${ff}">Paid</td>
|
||||||
|
<td style="font-size:13px;color:#374151;text-align:right;font-weight:500;padding:4px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:8px 0 0 0;border-top:1px solid #e2e8f0;font-family:${ff}">Balance</td>
|
||||||
|
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:8px 0 0 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
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 => `<tr>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtDate(p.createdAt)}</td>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${p.method || '—'}</td>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-weight:600;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(p.amount)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Payment received</p>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Thank you — we've got your payment</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||||
|
We received your payment of <strong>${fmtAmount(payment.amount)}</strong> for <strong>${eventTitle}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} received</strong><br/>
|
||||||
|
<span style="font-size:13px">Payment method: ${payment.method || '—'} • Date: ${fmtDate(payment.createdAt)}</span>`,
|
||||||
|
'success')}
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
|
||||||
|
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
|
||||||
|
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
|
||||||
|
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${historyRows ? `<p style="font-size:14px;font-weight:700;color:#0f172a;margin:24px 0 8px 0;font-family:${ff}">Payment history</p>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Date</th>
|
||||||
|
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Method</th>
|
||||||
|
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:right;font-family:${ff}">Amount</th>
|
||||||
|
</tr>${historyRows}
|
||||||
|
</table>` : ''}
|
||||||
|
|
||||||
|
${balance <= 0
|
||||||
|
? callout('<strong>You\'re fully paid!</strong> 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 = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Thank you for your donation!</p>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your generosity makes a difference</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||||
|
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} donated</strong><br/>
|
||||||
|
<span style="font-size:13px">Method: ${payment.method || '—'} • Date: ${fmtDate(payment.createdAt)}</span>`,
|
||||||
|
'success')}
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8;font-family:${ff}">We appreciate your generous support. Thank you!</p>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">A donation was applied to your registration</p>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Good news about your balance</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg?.user?.name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||||
|
A donation of <strong>${fmtAmount(payment.amount)}</strong> was applied to your registration for <strong>${eventTitle}</strong> by our team.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} applied</strong><br/>
|
||||||
|
<span style="font-size:13px">Date: ${fmtDate(payment.createdAt)}</span>`,
|
||||||
|
'success')}
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
|
||||||
|
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
|
||||||
|
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
|
||||||
|
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${balance <= 0
|
||||||
|
? callout('<strong>You\'re fully paid!</strong> 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 = `
|
||||||
|
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0">Payment recorded</p>
|
||||||
|
<p style="font-size:13px;color:#64748b;margin:0 0 28px 0">Internal notification</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<td colspan="2" style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Payment details</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;width:30%;font-family:${ff};border-top:1px solid #f1f5f9">Amount</td>
|
||||||
|
<td style="padding:10px 16px;font-size:15px;color:#059669;font-weight:800;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(payment.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Type</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${type}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Payer</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${payerName} <${payerEmail}></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Event</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${eventTitle}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Method</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${payment.method || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Date</td>
|
||||||
|
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtDate(payment.createdAt)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Payment ID</td>
|
||||||
|
<td style="padding:10px 16px;font-size:12px;color:#94a3b8;font-family:${ff};border-top:1px solid #f1f5f9">${payment.id}</td>
|
||||||
|
</tr>
|
||||||
|
${payment.externalId ? `<tr>
|
||||||
|
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">External ID</td>
|
||||||
|
<td style="padding:10px 16px;font-size:12px;color:#94a3b8;font-family:${ff};border-top:1px solid #f1f5f9">${payment.externalId}</td>
|
||||||
|
</tr>` : ''}
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Refund processed</p>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your refund is on its way</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${user?.name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||||
|
A refund of <strong>${fmtAmount(amt)}</strong> has been processed for <strong>${eventTitle}</strong>.
|
||||||
|
${payment.status ? `<br/>Reason: ${payment.status}` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${callout(`<strong style="font-size:15px">${fmtAmount(amt)} refunded</strong><br/>
|
||||||
|
<span style="font-size:13px">Method: ${payment.method || 'original payment method'} • Date: ${fmtDate(payment.createdAt)}</span>`,
|
||||||
|
'info')}
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0 0;font-size:14px;color:#374151;font-family:${ff}">
|
||||||
|
Refunds may take a few business days to appear depending on your bank and payment method.
|
||||||
|
If you have any questions, contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
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') =>
|
||||||
|
`<td style="text-align:center;padding:16px;border-right:1px solid #f1f5f9">
|
||||||
|
<div style="font-size:24px;font-weight:800;color:${color};font-family:${ff}">${value}</div>
|
||||||
|
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">${label}</div>
|
||||||
|
</td>`;
|
||||||
|
|
||||||
|
const regTableHtml = regRows.length
|
||||||
|
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;font-size:13px">
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Name</th>
|
||||||
|
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Email</th>
|
||||||
|
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Status</th>
|
||||||
|
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Total</th>
|
||||||
|
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Paid</th>
|
||||||
|
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Balance</th>
|
||||||
|
</tr>
|
||||||
|
${regRows.map((r, i) => `<tr style="background:${i % 2 === 0 ? '#ffffff' : '#f8fafc'}">
|
||||||
|
<td style="padding:10px 12px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${r.name}</td>
|
||||||
|
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${r.email}</td>
|
||||||
|
<td style="padding:10px 12px;border-top:1px solid #f1f5f9">
|
||||||
|
<span style="background:${r.status === 'paid' ? '#dcfce7' : r.status === 'partial_paid' ? '#fef9c3' : '#f1f5f9'};color:${r.status === 'paid' ? '#166534' : r.status === 'partial_paid' ? '#854d0e' : '#475569'};padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-family:${ff}">${r.status}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 12px;text-align:right;color:#374151;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.totalDue)}</td>
|
||||||
|
<td style="padding:10px 12px;text-align:right;color:#374151;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.totalPaid)}</td>
|
||||||
|
<td style="padding:10px 12px;text-align:right;color:${r.balance > 0 ? '#b45309' : '#059669'};font-weight:700;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.balance)}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</table>`
|
||||||
|
: `<p style="color:#94a3b8;font-size:14px;margin:0;font-family:${ff}">No registrations yet.</p>`;
|
||||||
|
|
||||||
|
const payTableHtml = payRows.length
|
||||||
|
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;font-size:13px">
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Date</th>
|
||||||
|
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Payer</th>
|
||||||
|
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Type</th>
|
||||||
|
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Method</th>
|
||||||
|
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Amount</th>
|
||||||
|
</tr>
|
||||||
|
${payRows.map((p, i) => `<tr style="background:${i % 2 === 0 ? '#ffffff' : '#f8fafc'}">
|
||||||
|
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${p.date}</td>
|
||||||
|
<td style="padding:10px 12px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${p.payerName}${p.payerEmail ? `<br/><span style="font-size:11px;color:#94a3b8">${p.payerEmail}</span>` : ''}</td>
|
||||||
|
<td style="padding:10px 12px;border-top:1px solid #f1f5f9">
|
||||||
|
<span style="background:${p.isDonation ? '#eff6ff' : '#f0fdf4'};color:${p.isDonation ? '#1e40af' : '#166534'};padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-family:${ff}">${p.isDonation ? 'Donation' : 'Registration'}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9">${p.method}</td>
|
||||||
|
<td style="padding:10px 12px;text-align:right;color:#059669;font-weight:700;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(p.amount)}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</table>`
|
||||||
|
: `<p style="color:#94a3b8;font-size:14px;margin:0;font-family:${ff}">No payments recorded yet.</p>`;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0;font-family:${ff}">Daily Summary</p>
|
||||||
|
<p style="font-size:13px;color:#64748b;margin:0 0 4px 0;font-family:${ff}">${new Date(now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</p>
|
||||||
|
<p style="font-size:16px;font-weight:700;color:#1e293b;margin:0 0 24px 0;font-family:${ff}">${ev.title}</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 32px 0;text-align:center">
|
||||||
|
<tr>
|
||||||
|
${statsRow('Total registrations', totalRegistrations)}
|
||||||
|
${statsRow('Confirmed paid', paidCount, '#059669')}
|
||||||
|
${statsRow('Awaiting payment', pendingCount, '#d97706')}
|
||||||
|
<td style="text-align:center;padding:16px">
|
||||||
|
<div style="font-size:24px;font-weight:800;color:#2563eb;font-family:${ff}">${fmtAmount(totalRevenue)}</div>
|
||||||
|
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">Total collected</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${divider()}
|
||||||
|
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 16px 0;font-family:${ff}">Registrations</p>
|
||||||
|
${regTableHtml}
|
||||||
|
|
||||||
|
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:32px 0 16px 0;font-family:${ff}">Payments & Donations</p>
|
||||||
|
${payTableHtml}
|
||||||
|
|
||||||
|
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0 0;font-family:${ff}">
|
||||||
|
Event starts: ${fmtDate(ev.startDate)} • Event ends: ${fmtDate(ev.endDate)}
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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<string>}
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
@@ -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<Array>} 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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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
|
||||||
@@ -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 |
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Generated
+8232
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user