Extends the earlier user-dashboard fix: canEditActive is renamed canModifyActive and now also gates the "Make payment" and "Cancel registration" actions, not just editing. Backend enforcement added to cancelRegistration and createYocoCheckout to block past-event self-service payment/cancellation server-side (cashup-closed events were already blocked via assertEventOpen; admins/supervisors are exempt from the past-date check, consistent with existing overrides). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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