Events and the organisation profile now have an address, with a "Directions" link and an embedded Google Maps view (no API key required) shown on event pages, event cards, and the Contact page. New events default their location to the org's configured address.
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
- Overview
- Tech Stack
- Prerequisites
- Installation
- Environment Variables
- Database
- Running the Server
- Authentication & Roles
- API Reference
- Background Workers
- Notification System
- WhatsApp Integration
- File Uploads
- Payments (Yoco)
- 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 |
| Nodemailer (any SMTP — configured via Admin → Site Settings, AES-256 encrypted in DB) | |
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.zafor production) - npm
Installation
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 fromJWT_SECRET. Do not changeJWT_SECRETafter 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
# 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
# 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 isemailorbothcanWhatsApp(user)— true if preference iswhatsapporbothand user has a phone numberwaText(user, message)— sends a WhatsApp textwaPdf(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(fieldimage), saved topublic/uploads/ - Event attachments:
POST /api/events/:id/attachments(fieldfile) - 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
priceSnapshotandappliedTierIdon eachRegistrationOption. - 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
resolveOptionPricefor everyRegistrationOptionthat has anappliedTierId. - If a tier has since expired or its stock has been exhausted,
priceSnapshotandappliedTierIdare updated in the DB to reflect the next applicable tier or the base price. - If prices changed,
POST /api/payments/yoco-checkoutreturns 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
priceSnapshotas the authoritative price when it is set on aRegistrationOption. - 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)
- Frontend calls
POST /api/payments/yoco-checkout - 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. - On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns
{ redirectUrl }. - User pays on Yoco's hosted page.
- Yoco sends webhook to
POST /api/webhooks/yoco. - Backend verifies HMAC, creates
Payment+YocoTransaction, updates registration status, generates tickets. - Unmatched transactions visible at
/api/yoco-transactions/unreconciled.
Deployment
PM2 (recommended)
npm install -g pm2
pm2 start src/index.js --name hope-events-api
pm2 save && pm2 startup
Checklist
DATABASE_URLpoints to production DBJWT_SECRETis long (32+ chars) and secretFRONTEND_URLlists only production originsNODE_ENV=production- Yoco webhook registered and HTTPS
- WAWP credentials saved via admin panel
- SMTP credentials correct