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
+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