# Hope Events — Backend API Node.js + Express REST API for the Hope Events platform used by Hope Family Church (`events.hopehenley.co.za`). --- ## Table of Contents 1. [Overview](#overview) 2. [Tech Stack](#tech-stack) 3. [Prerequisites](#prerequisites) 4. [Installation](#installation) 5. [Environment Variables](#environment-variables) 6. [Database](#database) 7. [Running the Server](#running-the-server) 8. [Authentication & Roles](#authentication--roles) 9. [API Reference](#api-reference) 10. [Background Workers](#background-workers) 11. [Notification System](#notification-system) 12. [WhatsApp Integration](#whatsapp-integration) 13. [File Uploads](#file-uploads) 14. [Payments (Yoco)](#payments-yoco) 15. [Deployment](#deployment) --- ## Overview Full-stack event management backend handling: - User registration, authentication (JWT), and role-based access control - Event creation, management, and public listings - Attendee registration with optional payment tiers and early-bird pricing - Yoco checkout integration with webhook reconciliation - Ticket generation (PDF + QR code) and email/WhatsApp delivery - Scheduled bulk messaging (email + WhatsApp) for attendees and broadcast lists - Admin WhatsApp instance management via WAWP --- ## Tech Stack | Layer | Technology | |-------|-----------| | Runtime | Node.js 18+ | | Framework | Express 4 | | Database | PostgreSQL (via Prisma ORM 5) | | Auth | JWT (`jsonwebtoken`), `bcryptjs` | | Email | Nodemailer (any SMTP — configured via Admin → Site Settings, AES-256 encrypted in DB) | | WhatsApp | WAWP REST API (`api.wawp.net/v2`) | | PDF/QR | PDFKit, QRCode | | File Uploads | Multer | | Payments | Yoco | --- ## Prerequisites - Node.js 18+ - PostgreSQL 14+ (hosted at `database.crosscode.co.za` for production) - npm --- ## Installation ```bash cd backend npm install # installs deps and runs `prisma generate` ``` --- ## Environment Variables Create `backend/.env` from `.env.example`. The only variables you must set are: | Variable | Required | Description | |----------|----------|-------------| | `DATABASE_URL` | Yes | PostgreSQL connection string | | `JWT_SECRET` | Yes | Secret key for JWTs (32+ random characters). Also used to derive the AES-256 encryption key for sensitive settings (SMTP credentials) stored in the DB — must be set before saving SMTP settings. | | `FRONTEND_URL` | Yes | Comma-separated allowed CORS origins | | `YOCO_SECRET_KEY` | Yes | Yoco secret API key | | `YOCO_WEBHOOK_SECRET` | Yes | Yoco webhook HMAC secret | | `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) | | `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp | | `PORT` | — | Port to listen on (default `3000`) | | `NODE_ENV` | — | `production` or `development` | | `WAWP_ACCESS_TOKEN` | — | WAWP fallback token (preferred: set via Admin → Site Settings) | | `WAWP_INSTANCE_ID` | — | WAWP fallback instance ID (preferred: set via Admin → Site Settings) | | `DAILY_SUMMARY_ENABLED` | — | `false` to disable daily summaries (default `true`) | | `SCHEDULED_EMAILS_ENABLED` | — | `false` to disable scheduled-send worker (default `true`) | | `SCHEDULED_EMAILS_INTERVAL_MS` | — | Polling interval in ms (default `30000`) | | `SMTP_HOST` / `EMAIL_HOST` | — | Fallback SMTP host (preferred: set via Admin → Site Settings) | | `SMTP_PORT` / `EMAIL_PORT` | — | Fallback SMTP port | | `SMTP_USER` / `EMAIL_USER` | — | Fallback SMTP username | | `SMTP_PASS` / `EMAIL_PASS` | — | Fallback SMTP password | | `MAIL_FROM` / `EMAIL_FROM` | — | Fallback from address | > **Organisation name, branding colour, logo, registration notification email, SMTP settings, and legal page content** are now managed through the admin panel at **Admin → Site Settings** and stored in the database. You no longer need `ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, or SMTP vars in `.env` (they still work as fallbacks). On a fresh deployment these are set via the first-time setup wizard. > > **SMTP credentials** (`smtp_user`, `smtp_pass`) are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving SMTP credentials — the stored values will become unreadable. --- ## Database ### Schema summary | Model | Purpose | |-------|---------| | `User` | Accounts with roles (`admin`, `supervisor`, `staff`, `user`) | | `Event` | Events with dates, pricing, options, attachments | | `EventOption` | Ticket types / items per event; `stockLimit` (0 = unlimited), `isMainTicket` flag | | `EarlyBirdTier` | Time-limited discounted prices; linked to an `EventOption` (option-level) or an `OptionVariant` (variant-level via nullable `variantId`); `stockLimit` (0 = unlimited) | | `OptionVariant` | Sub-items per option (size, colour, ticket type); optional `price` override; own `stockLimit` | | `Registration` | A user's registration for an event; `status`: pending/confirmed/partial_paid/paid/cancelled | | `RegistrationOption` | Which options (and quantities) a registration includes; `priceSnapshot`, `appliedTierId`, `variantId` | | `Payment` | Payment records (Yoco, manual, donation, refund) | | `Ticket` | QR-code tickets linked to registration options | | `TicketUsage` | Scan log for each ticket redemption | | `PasswordReset` | One-time tokens for password reset flow | | `EventAttachment` | Files attached to events (PDFs, images) | | `EventForm` / `EventFormField` | Per-event custom forms | | `FormResponse` / `FormAnswer` | Submitted form data from attendees | | `FormDraft` | Auto-saved in-progress form data | | `YocoTransaction` | Raw webhook events from Yoco for reconciliation | | `Section` / `SectionOption` | Grouping of event options into sections | | `AppSetting` | Key-value store for runtime config — WAWP credentials, org name, branding, notification emails, SMTP settings (credentials stored AES-256-GCM encrypted), legal page content | ### Migrations ```bash # Apply pending migrations (production) npm run prisma:deploy # Check migration status npm run prisma:status # Regenerate Prisma client after schema changes npm run prisma:generate ``` --- ## Running the Server ```bash # Development (auto-reload) npm run dev # Production npm start ``` Visit `/` for the status page. Visit `/docs?token=` for full API documentation (admin only). --- ## Authentication & Roles All protected routes require a `Bearer` token in the `Authorization` header: ``` Authorization: Bearer ``` Tokens are issued on `POST /api/users/login`. Token version is stored per-user; revoking sessions increments the version and invalidates all existing tokens for that user. ### Role Hierarchy | Role | Description | |------|-------------| | `admin` | Full access — user management, delete operations, WhatsApp admin, payment stats | | `supervisor` | Event/registration/payment management, bulk messaging, reports | | `staff` | Read access to registrations, payments, tickets; ticket scanning | | `user` | Own profile, own registrations, own tickets | ### Middleware | Middleware | Applied when | |-----------|-------------| | `protect` | Any authenticated route | | `admin` | Admin-only actions | | `supervisor` | Supervisor + admin | | `staff` | Staff + supervisor + admin | | `optionalAuth` | Routes that work for guests but enrich responses if authenticated | | `loginLimiter` | Rate-limits login to 15 req/min per IP | --- ## API Reference Full interactive API docs available at `GET /docs` (requires admin JWT — pass via `?token=` query param or `Authorization: Bearer` header). ### Quick reference by domain #### Users — `/api/users` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/` | public | Register a new account | | POST | `/login` | public | Login, returns JWT | | POST | `/forgot` | public | Request password reset email | | POST | `/reset` | public | Reset password with token | | POST | `/activate` | public | Activate account from email link | | GET | `/profile` | user+ | Get own profile | | PUT | `/profile` | user+ | Update own profile | | POST | `/revoke-sessions` | user+ | Revoke own sessions | | POST | `/close-account` | user+ | Deactivate own account | | GET | `/` | supervisor+ | List all users (paginated); supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` | | GET | `/:id` | admin | Get user by ID | | PUT | `/:id` | admin | Update user | | DELETE | `/:id` | admin | Deactivate user (sets `isActive: false`) | | POST | `/:id/revoke-sessions` | admin | Revoke all sessions for a user | | POST | `/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account, revokes all sessions | #### Events — `/api/events` | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/` | public | List public/active upcoming events; response includes `isSoldOut: boolean` per event | | GET | `/all` | staff+ | All events including hidden/past | | GET | `/admin/all` | admin | All events (admin view) | | GET | `/attachments/status` | admin | Attachment manifest sync status | | POST | `/attachments/sync` | admin | Re-sync attachment manifests to DB | | GET | `/:id` | public | Get event by ID | | GET | `/by-alias/:redirectUrl` | public | Get event by redirect alias | | POST | `/` | supervisor+ | Create event | | PUT | `/:id` | supervisor+ | Update event | | DELETE | `/:id` | admin | Delete event | | GET | `/:id/attachments` | public | List event file attachments | | POST | `/:id/attachments` | supervisor+ | Upload attachment | | DELETE | `/:eventId/attachments/:id` | supervisor+ | Delete attachment | | POST | `/:id/email-attendees` | supervisor+ | Bulk email attendees (supports `dryRun`) | | POST | `/:id/email-attendees/schedule` | supervisor+ | Schedule bulk email | | POST | `/:id/whatsapp-attendees` | supervisor+ | Bulk WhatsApp to attendees (supports `dryRun`) | | POST | `/:id/whatsapp-attendees/schedule` | supervisor+ | Schedule bulk WhatsApp | | POST | `/:id/options` | supervisor+ | Add event option | | PUT | `/options/:id` | supervisor+ | Update event option | | DELETE | `/options/:id` | admin | Delete event option | #### Registrations — `/api/registrations` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/` | optional | Create registration | | GET | `/myregistrations` | user+ | Own registrations | | PUT | `/:id/options` | user+ | Update registration options | | DELETE | `/:id` | user+ | Cancel registration | | GET | `/:id` | optional | Get registration by ID | | GET | `/:id/forms/draft` | optional | Get form draft | | PUT | `/:id/forms/draft` | optional | Save form draft | | POST | `/:id/forms/responses` | optional | Submit form responses | | PUT | `/:id/forms/responses` | supervisor+ | Replace form responses | | GET | `/` | staff+ | List all registrations | | PUT | `/:id` | staff+ | Update registration status | | GET | `/event/:eventId` | staff+ | Registrations for an event | | POST | `/manual` | supervisor+ | Manual registration | #### Payments — `/api/payments` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/` | supervisor+ | Record manual payment | | POST | `/yoco-checkout` | user+ | Initiate Yoco checkout | | GET | `/mypayments` | user+ | Own payments | | GET | `/:id` | user+ | Payment by ID | | GET | `/registration/:registrationId` | user+ | Payments for a registration | | GET | `/` | supervisor+ | All payments | | GET | `/event/:eventId` | staff+ | Payments for an event | | PUT | `/assign-donation` | supervisor+ | Assign donation to registration | | POST | `/refund` | supervisor+ | Create refund | | GET | `/admin/stats` | admin | Payment statistics | #### Tickets — `/api/tickets` | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/mytickets` | user+ | Own tickets | | GET | `/:id` | user+ | Ticket by ID | | POST | `/email` | user+ | Email own tickets | | POST | `/send-to` | staff+ | Send tickets to a specific user | | GET | `/scan-preview/:qrCode` | staff+ | Preview scan (no mark) | | GET | `/qr/:qrCode` | staff+ | Ticket by QR code | | POST | `/scan/:qrCode` | staff+ | Scan/redeem ticket | | GET | `/event/:eventId` | staff+ | All tickets for event | | GET | `/scans/recent` | staff+ | Recent scan activity | | GET | `/scans/stats` | staff+ | Scan statistics | | POST | `/generate` | supervisor+ | Generate tickets for registration | | GET | `/` | supervisor+ | All tickets | | PUT | `/email-sent` | admin | Bulk mark tickets as emailed | #### Webhooks — `/api/webhooks` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/yoco` | HMAC | Yoco payment webhook | | POST | `/whatsapp` | — | WAWP inbound webhook | #### Broadcasts — `/api/broadcasts` (email) | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/preview` | supervisor+ | Dry-run, returns recipient count/sample | | POST | `/send` | supervisor+ | Send email broadcast | | POST | `/schedule` | supervisor+ | Schedule email broadcast | #### WhatsApp Broadcasts — `/api/whatsapp-broadcasts` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/preview` | supervisor+ | Dry-run | | POST | `/send` | supervisor+ | Send WA broadcast | | POST | `/schedule` | supervisor+ | Schedule WA broadcast | #### Scheduled Messages — `/api/scheduled-emails` | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/` | supervisor+ | List all scheduled jobs | | PATCH | `/:id` | supervisor+ | Edit a queued job | | DELETE | `/:id` | supervisor+ | Cancel a queued job | #### Automations — `/api/automations` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/schedule` | supervisor+ | Schedule event lifecycle automations | #### Reports — `/api/reports` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/pdf` | user+ | Generate and download PDF report | | POST | `/email` | user+ | Email PDF report to current user | #### Settings — `/api/settings` | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/` | public | Public settings (org name, colour, logo, legal keys, etc.) | | GET | `/all` | admin | All settings — `smtp_pass` returned masked (`••••••••`), `smtp_user` decrypted | | GET | `/needs-setup` | public | `{ needsSetup: bool }` — true until setup wizard completes | | PUT | `/` | admin | Upsert settings; `smtp_user` and `smtp_pass` are AES-256-GCM encrypted before storage; sending `••••••••` for `smtp_pass` is a no-op (keeps existing value) | | POST | `/test-smtp` | admin | Test SMTP connection with provided credentials; success returns `{ message }`, failure returns `{ message, raw }` where `message` is human-readable and `raw` is the original SMTP error. The short-lived admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. | #### Setup — `/api/setup` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/register` | public (one-time) | Step 2 of setup wizard — creates the first admin account and returns a short-lived JWT setup token; blocked once any user exists | | POST | `/` | setup token or admin | Final step of setup wizard — saves initial site settings (SMTP, org name, branding); requires setup token from `/register` | #### Uploads — `/api/uploads` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/event-image` | supervisor+ | Upload event cover image (`image` field) | | POST | `/logo` | admin (or no-auth during setup) | Upload site logo (`image` field) | #### Sections — `/api/sections` | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/` | staff+ | List sections | | POST | `/` | supervisor+ | Create section | | PUT | `/:id` | supervisor+ | Update section | | DELETE | `/:id` | admin | Delete section | #### Banner — `/api/banner` | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/` | public | Get current site banner | | POST | `/` | supervisor+ | Set site banner | #### Forms — `/api/forms` | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/responses` | staff+ | List all form responses | #### Yoco Transactions — `/api/yoco-transactions` | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/` | supervisor+ | All Yoco webhook transactions | | GET | `/unreconciled` | supervisor+ | Unmatched transactions | | POST | `/:id/reconcile` | supervisor+ | Manually reconcile transaction | | POST | `/:id/ignore` | supervisor+ | Mark transaction as ignored | #### WhatsApp Admin — `/api/whatsapp` | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/webhook` | public | WAWP inbound event | | GET | `/config` | admin | Get WAWP credentials | | POST | `/config` | admin | Save WAWP credentials to DB | | POST | `/create-instance` | admin | Create WAWP instance | | POST | `/delete-instance` | admin | Delete WAWP instance | | GET | `/status` | admin | WAWP session status | | GET | `/qr` | admin | QR code for WhatsApp linking | | POST | `/request-code` | admin | Request pairing code | | POST | `/logout` | admin | Logout WAWP session | | POST | `/start` | admin | Start WAWP instance | | POST | `/restart` | admin | Restart WAWP instance | #### Static Files | Path | Description | |------|-------------| | `/uploads/*` | Served uploaded files (images, PDFs) | --- ## Background Workers All workers start automatically with `npm start`. ### Scheduled Emails Worker Polls every 30 s (configurable via `SCHEDULED_EMAILS_INTERVAL_MS`) for due jobs. Handles attendee emails, attendee WhatsApp, email broadcasts, and WhatsApp broadcasts. Disable with `SCHEDULED_EMAILS_ENABLED=false`. ### Daily Event Summaries Runs at 07:00 local time. Sends summary emails to supervisors about that day's events. Disable with `DAILY_SUMMARY_ENABLED=false`. ### Temp File Cleanup Runs at 03:00 local time. Removes files older than 7 days from `public/uploads/tickets-temp/`. ### Attachment Manifest Sync Runs once on startup. Imports on-disk attachment manifests not yet in the database. --- ## Notification System Outbound notifications are routed through `src/utils/notify.js`: - `shouldEmail(user)` — true if preference is `email` or `both` - `canWhatsApp(user)` — true if preference is `whatsapp` or `both` and user has a phone number - `waText(user, message)` — sends a WhatsApp text - `waPdf(user, pdfPath, caption)` — copies PDF to temp, sends URL to WAWP, cleans up after 5 min **Automated notifications**: login, account deletion, ticket delivery, payment confirmation, password reset, daily summaries. **Dynamic placeholders** in bulk messages: `{{name}}`, `{{event.title}}`, `{{event.start}}`, `{{event.link}}`, `{{balance}}`, `{{promo.title}}`, `{{promo.link}}`. --- ## WhatsApp Integration WAWP API (`api.wawp.net/v2`). All calls go through `src/utils/whatsapp.js`. Credentials are stored in `AppSetting` (`WAWP_ACCESS_TOKEN`, `WAWP_INSTANCE_ID`), with `.env` as fallback. Cached in memory for 30 s. "Session not found" recovery: stale session is deleted and a new one is created automatically, saving the new `instance_id` to `AppSetting`. Admin UI: `/dashboard/admin/whatsapp`. --- ## File Uploads - Event images: `POST /api/uploads/event-image` (field `image`), saved to `public/uploads/` - Event attachments: `POST /api/events/:id/attachments` (field `file`) - Ticket PDFs: generated on demand, temporarily in `public/uploads/tickets-temp/`, auto-deleted after 5 min Files are served from `/uploads/*`. --- ## Pricing & Cancellation Rules ### Cancellation `DELETE /api/registrations/:id` | Actor | Condition | Result | |-------|-----------|--------| | Owner (user) | No positive payments recorded | 200 — status set to `cancelled` | | Owner (user) | Any positive payment exists | 403 — must contact organisation | | Admin | Any state | 200 — always allowed | | Anyone | Already cancelled | 400 | Cancelled registrations are excluded from all stock-limit counts (option, variant, and tier stock). ### Early-bird pricing — two-phase evaluation Prices are evaluated in two phases: **Phase 1 — Registration time** (`resolveOptionPrice` in `src/utils/pricing.js`) - Selects the cheapest applicable tier, checking **both** the deadline **and** the stock limit. - Result stored as `priceSnapshot` and `appliedTierId` on each `RegistrationOption`. - Tier sort order: cheapest first; earliest deadline breaks ties. **Phase 2 — Payment time** (`refreshPricingForRegistration` in `src/utils/pricing.js`) - Called automatically before any Yoco checkout is created or any manual payment is recorded. - Re-runs `resolveOptionPrice` for every `RegistrationOption` that has an `appliedTierId`. - If a tier has since expired **or its stock has been exhausted**, `priceSnapshot` and `appliedTierId` are updated in the DB to reflect the next applicable tier or the base price. - If prices changed, `POST /api/payments/yoco-checkout` returns HTTP 200 with `{ priceUpdated: true, newTotal, message }` instead of proceeding to Yoco. The frontend must present the updated total before the user can retry. - For manual payments (`POST /api/payments`) prices are refreshed silently — no blocking response. **`computeRegistrationTotalDue`** (also in `src/utils/pricing.js`) - Uses `priceSnapshot` as the authoritative price when it is set on a `RegistrationOption`. - Falls back to `getEffectiveUnitPrice` (deadline-only re-evaluation) for legacy rows without a snapshot. ### Variant + tier interaction Early-bird tiers can be defined at either the **option level** (no `variantId`) or the **variant level** (`variantId` set). Both are stored as `EarlyBirdTier` records on the `EventOption`. | Variant state | Effective price | |---------------|-----------------| | No variant selected | Option-level tiers are evaluated via `resolveOptionPrice` | | Variant selected, variant has its own tier(s) | Variant-level tiers are evaluated first via `resolveVariantTierPrice` | | Variant selected, no variant-level tier | Variant's own `price` is used (or option price if `variant.price` is null) | The resolution priority at registration/payment time is: **variant tier → variant price → option tier → option base price**. --- ## Payments (Yoco) 1. Frontend calls `POST /api/payments/yoco-checkout` 2. Backend calls `refreshPricingForRegistration` — if any early-bird price changed, returns `{ priceUpdated: true, newTotal }` (HTTP 200) and does **not** create a checkout. Frontend shows warning; user retries. 3. On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns `{ redirectUrl }`. 4. User pays on Yoco's hosted page. 5. Yoco sends webhook to `POST /api/webhooks/yoco`. 6. Backend verifies HMAC, creates `Payment` + `YocoTransaction`, updates registration status, generates tickets. 7. Unmatched transactions visible at `/api/yoco-transactions/unreconciled`. --- ## Deployment ### PM2 (recommended) ```bash npm install -g pm2 pm2 start src/index.js --name hope-events-api pm2 save && pm2 startup ``` ### Checklist - [ ] `DATABASE_URL` points to production DB - [ ] `JWT_SECRET` is long (32+ chars) and secret - [ ] `FRONTEND_URL` lists only production origins - [ ] `NODE_ENV=production` - [ ] Yoco webhook registered and HTTPS - [ ] WAWP credentials saved via admin panel - [ ] SMTP credentials correct