Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+36
View File
@@ -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/
+198
View File
@@ -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 %; 51200: 15 %; 2011,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.
+53
View File
@@ -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).
+525
View File
@@ -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.*
+528
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"message": "",
"type": "info",
"liveFrom": null,
"liveTill": null
}
+3
View File
@@ -0,0 +1,3 @@
{
"jobs": []
}
+2260
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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;
@@ -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"
+636
View File
@@ -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])
// }
+197
View File
@@ -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);
});
+134
View File
@@ -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());
+25
View File
@@ -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,
};
+5
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// 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 };
+186
View File
@@ -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
};
+177
View File
@@ -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
+52
View File
@@ -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
+275
View File
@@ -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 };
+141
View File
@@ -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,
};
+795
View File
@@ -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
};
+103
View File
@@ -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,
};
+934
View File
@@ -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

+1253
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -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 };
+20
View File
@@ -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 };
+9
View File
@@ -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;
+9
View File
@@ -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;
+11
View File
@@ -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;
+12
View File
@@ -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;
+12
View File
@@ -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;
+86
View File
@@ -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;
+9
View File
@@ -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;
+33
View File
@@ -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;
+37
View File
@@ -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;
+12
View File
@@ -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;
+16
View File
@@ -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;
+12
View File
@@ -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;
+12
View File
@@ -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;
+13
View File
@@ -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;
+39
View File
@@ -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;
+41
View File
@@ -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;
+52
View File
@@ -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;
+9
View File
@@ -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;
+37
View File
@@ -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;
+107
View File
@@ -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 };
+204
View File
@@ -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
};
+39
View File
@@ -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 };
+389
View File
@@ -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}&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;</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} &bull;
<a href="mailto:${org.email}" style="color:#94a3b8;text-decoration:underline">${org.email}</a> &bull;
<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,
};
+79
View File
@@ -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 };
+43
View File
@@ -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 };
+984
View File
@@ -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 || '—'} &bull; 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 || '—'} &bull; 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} &lt;${payerEmail}&gt;</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'} &bull; 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 &amp; Donations</p>
${payTableHtml}
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0 0;font-family:${ff}">
Event starts: ${fmtDate(ev.startDate)} &bull; 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,
};
+82
View File
@@ -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 };
+248
View File
@@ -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,
};
+108
View File
@@ -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,
};
+79
View File
@@ -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 };
+145
View File
@@ -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 };
+258
View File
@@ -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,
};
+294
View File
@@ -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,
};
+41
View File
@@ -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
+318
View File
@@ -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 %; 51200: 15 %; 2011 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 |
+21
View File
@@ -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"
}
+16
View File
@@ -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;
+15
View File
@@ -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;
+8232
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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"
}
}
+6
View File
@@ -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