# Hope Events — API Documentation Full interactive docs (with request/response examples) are available at `GET /docs?token=` on the running backend. All authenticated requests require: ``` Authorization: Bearer ``` 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) - [Coupons](#coupons) - [Parental consent](#parental-consent) - [Waitlist](#waitlist) - [Offline scanning](#offline-scanning) - [Ticket transfer](#ticket-transfer) - [Rooms & groups](#rooms--groups) - [Sponsor a spot](#sponsor-a-spot) - [Post-event feedback](#post-event-feedback) - [Registration funnel](#registration-funnel) - [Impersonation](#impersonation) - [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 — or a two-factor challenge when the account has 2FA on | | POST | `/api/users/login/2fa` | public | Exchange a two-factor code for the JWT | | POST | `/api/users/login/2fa/resend` | public | Send a new code for a pending login challenge (60s cooldown) | | 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 | | POST | `/api/users/2fa/enable` | user+ | Start turning on 2FA — sends a code to the chosen channel | | POST | `/api/users/2fa/confirm` | user+ | Finish turning on 2FA with that code | | POST | `/api/users/2fa/disable` | user+ | Turn off 2FA (requires password) | | POST | `/api/users/verify-password` | user+ | Re-confirm own password without starting a new session | | 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). `twoFactorEnabled: false` resets a user's 2FA; it cannot be switched on this way | | 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", "deviceToken": "optional-trusted-device-token" } ``` Returns: `{ _id, name, email, role, token }` If the account has two-factor authentication on and `deviceToken` is missing, expired or not recognised, no token is issued. A 6-digit code is sent by email or WhatsApp and the response is instead: ```json { "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" } ``` **Required for admins and supervisors.** An admin or supervisor without 2FA gets the same challenge with `"enrolling": true` (trusted devices don't skip it), sent to their email when SMTP is configured, otherwise WhatsApp. Verifying it turns 2FA on with that channel. If neither channel can deliver a code, the requirement is skipped so the account isn't locked out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off. ### POST /api/users/login/2fa ```json { "challengeId": "uuid", "code": "123456", "rememberDevice": true } ``` Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberDevice` is `true`. Send that `deviceToken` with later logins to skip the code for 30 days. Codes expire after 10 minutes and allow 5 attempts. Trusted devices are cleared when the password changes or sessions are revoked. ### Two-factor setup (`/api/users/2fa/*`) - `POST /api/users/2fa/enable` — `{ "method": "email" | "whatsapp", "password": "..." }` sends a code to that channel and returns `{ challengeId, method, destination }`. WhatsApp needs a valid phone number on the account and a connected WhatsApp session. - `POST /api/users/2fa/confirm` — `{ "challengeId": "uuid", "code": "123456" }` turns 2FA on. - `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off. Returns 403 for admins and supervisors, who must keep it on (they can switch channel with `enable`/`confirm`). An admin or supervisor session without 2FA (from before the requirement, or after a promotion or 2FA reset) is rejected by every protected route with `401 { "code": "TWO_FACTOR_REQUIRED" }`; logging in again enrols them. Promoting a user to supervisor/admin ends their sessions when they don't have 2FA yet. At login, if a WhatsApp code can't be sent, the code falls back to the account's email address. ### 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: ""`). --- ## 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 (accepts `couponCode`) | | PUT | `/api/registrations/:id/attendees` | user+ | Replace the named attendees (`{ attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }`); owner or staff | | POST | `/api/registrations/:id/attendees/:attendeeId/send-form-link` | user+ | Email/WhatsApp an attendee the link to their own form | | POST | `/api/registrations/:id/coupon` | user+ | Apply a code to an existing registration (owner, or staff on someone's behalf) | | DELETE | `/api/registrations/:id/coupon` | staff+ | Remove the code (refused once a ticket on a paid registration has been scanned) | ### POST /api/registrations ```json { "eventId": "ev-uuid-...", "options": [ { "eventOptionId": "opt-uuid-...", "quantity": 2 }, { "eventOptionId": "opt-uuid-...", "quantity": 1, "variantId": "variant-uuid-..." } ] } ``` Add `"couponCode": "STAFF50"` to apply a discount code. The code is checked before anything is saved (400 with the reason if it can't be used). If the last use is taken by someone else in the moment between checking and saving, the registration is still created at full price and the response includes `couponError`. **Group tickets:** on events with `allowNamedAttendees`, `POST /api/registrations` and `/manual` accept `attendees: [{ eventOptionId, variantId?, name, email?, phone? }]` (main-ticket places only, no more than the places bought) and `ticketDelivery: "payer" | "each"`. - Each named attendee gets their own quantity-1 ticket; unnamed places share one quantity ticket. - An attendee whose email/phone matches one existing account is linked to it, so the ticket also appears in that person's `GET /api/tickets/mytickets`. - With `"each"`, tickets are emailed/WhatsApped to attendees directly, falling back to the payer. - Form responses can carry `attendeeId`. Registration responses include `discountTotal` (the coupon discount), `subtotalComputed` (items before the discount), `totalDueComputed` (after it) and `couponRedemptions` (the active code). 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 | --- ## Impersonation | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/api/admin/impersonate/:userId` | admin (2FA on) | `{ reason }` → `{ token, session }`. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users | | POST | `/api/admin/impersonate/end` | impersonation token | End the session | | POST | `/api/admin/impersonate/log` | impersonation token | `{ path }` — page viewed (audit trail) | | GET | `/api/admin/impersonate/current` | impersonation token | The live session | | GET | `/api/admin/impersonation-sessions` | admin | Sessions with their request trail | Read-only enforcement: - A guard mounted before every route returns `403 { code: "IMPERSONATION_READ_ONLY" }` for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions are `end` and `log`. Blocked attempts are logged. - An ended or expired session gets `401 { code: "IMPERSONATION_ENDED" }`. - GET handlers skip side-effect writes (pricing refresh) while `req.readOnly` is set. ## Registration funnel | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/api/analytics/track` | public (rate-limited) | `{ sessionId, eventId, stage: 'view'\|'start', source? }` → always `204`. Bots are ignored; device type comes from the User-Agent | | GET | `/api/analytics/funnel?eventIds=&from=&to=` | supervisor+ | `{ totals, conversion, trend, bySource, byDevice, byOption, perEvent }` | How the stages are recorded: - `POST /api/registrations` accepts `analyticsSessionId`. It's stored on the registration and records the `submit` stage. - `paid` is read from that registration's status. - Counts are sessions that reached a stage or a later one. Data handling: - Sessions inactive for 90 days are rolled up into `AnalyticsDaily` by a daily job (`ANALYTICS_ROLLUP_ENABLED`). - `/api/stats/overview` now includes `funnel: { views, paid, conversion }` for live events over the past 30 days. ## Post-event feedback | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/feedback/event/:eventId` | supervisor+ | The event's feedback form (with `invited` / `responses` counts), or null | | PUT | `/api/feedback/event/:eventId` | supervisor+ | `{ sendAt, audience: 'checked_in'\|'all', anonymous, intro, fields[] }` — fields can't change once answered (409) | | DELETE | `/api/feedback/event/:eventId` | supervisor+ | Remove the form | | POST | `/api/feedback/event/:eventId/send-now` | supervisor+ | Send now (also invites anyone not yet invited) | | GET / POST | `/api/feedback/public/:token` | link holder | Load the form / submit `{ answers: { fieldId: value } }` once | | GET | `/api/feedback/summary?eventIds=a,b` | supervisor+ | Per event: invited, responses, response rate, and per question: rating average + distribution, choice counts, text answers (names only when not anonymous) | Notes: - New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too. - Only HMACs of link tokens are stored, and a reminder issues a new link. - A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered. ## Sponsor a spot | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/sponsorships/event/:eventId` | public | `{ enabled, open, spotPrice, showProgress, goalSpots, spotsFunded, sponsors }` — never recipients | | POST | `/api/sponsorships/checkout` | public (optional login) | `{ eventId, spots? \| amount?, anonymous?, sponsorName?, message?, name?, email? }` → Yoco `redirectUrl`. Guests give a name and email | | GET | `/api/sponsorships/event/:eventId/pool` | supervisor+ | `{ received, assigned, remaining, sponsors, spotsFunded, spotPrice, goalSpots }` | How the money is handled: - A spot costs the cheapest paid main ticket. - Yoco checkout metadata carries `type: 'sponsorship'`. The webhook records the payment as `isDonation` + `isSponsorship` against the event, together with `sponsorName`, `sponsorMessage` and `sponsorAnonymous`. - Pool funds are assigned with `PUT /api/payments/assign-donation`. Other changes: - Event fields: `allowSponsorships`, `sponsorshipGoalSpots`, `showSponsorshipProgress`. - `POST /api/events/:id/email-attendees` accepts `filter.audience: 'sponsors'`. - `POST /api/automations/schedule` jobs accept `audience: 'sponsors'`. ## Rooms & groups | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/allocations/event/:eventId` | supervisor+ | Board: sets (groups, assignments), named attendees, possible leaders, unnamed places | | POST | `/api/allocations/sets` | supervisor+ | `{ eventId, name, showOnTicket? }` | | PUT / DELETE | `/api/allocations/sets/:id` | supervisor+ | Rename / show on tickets; delete | | POST | `/api/allocations/sets/:id/groups` | supervisor+ | `{ name, capacity?, gender? ('male'\|'female'), leaderUserId? }` | | PUT / DELETE | `/api/allocations/groups/:id` | supervisor+ | Edit (can't drop below current members or break gender); delete (members become unassigned) | | PUT | `/api/allocations/sets/:id/assignments` | supervisor+ | `{ attendeeId, groupId }` (null unassigns); enforces capacity and gender | | DELETE | `/api/allocations/sets/:id/assignments` | supervisor+ | Clear the set | | POST | `/api/allocations/sets/:id/auto` | supervisor+ | `{ mode: 'together' \| 'split' }` — places everyone not yet placed | | GET | `/api/allocations/sets/:id/lists?medical=true` | supervisor+ | Groups with members for printing (medical view is audited) | | GET | `/api/allocations/my-groups` | staff+ | Groups the caller leads (medical details for supervisors only) | Related changes: - `Event.collectGender` makes a gender (`male` / `female`) required for each named attendee. - Email and WhatsApp attendees accept `filter.allocationGroupId`. ## Ticket transfer | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }` → `201`. Creates an offer and sends a link to the details given. **Nothing changes on the ticket yet.** A new offer replaces an open one | | DELETE | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | Withdraw the open offer | | GET | `/api/tickets/transfer-offer/:token` | link holder | `{ status, toName, fromName (first name), event, option, expiresAt }` | | POST | `/api/tickets/transfer-offer/:token/accept` | link holder | Accept — the transfer happens now (all checks run again) | | POST | `/api/tickets/transfer-offer/:token/decline` | link holder | Decline | | GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history | Privacy: - There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account. - Offers expire after 7 days or when the event starts. - Only an HMAC of the link token is stored. Rules: - Only before the event starts, and only for tickets that have never been scanned. - `Event.allowTicketTransfer` turns it off for non-supervisors. What a transfer does: - The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket. - An account is matched by email/phone, or created inactive with an activation link. - The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request. Old QR codes: - `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`. - Offline sync reports them as `transferred`. - Each transfer is audited as `ticket_transferred`. ## Offline scanning | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/scan-locks?eventId=` | staff+ | Active offline locks for an event | | POST | `/api/scan-locks` | staff+ | Lock `{ eventId, sectionId?, deviceId, deviceName? }` to this device (409 if the scope is held elsewhere) | | GET | `/api/scan-locks/:id/snapshot?deviceId=` | staff+ | Tickets valid in the lock's scope (lock holder only) | | POST | `/api/scan-locks/:id/sync` | staff+ | `{ deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }` — idempotent; returns accepted, duplicates, conflicts | | DELETE | `/api/scan-locks/:id` | staff+ | Release (`{ deviceId }`, lock holder only) | | POST | `/api/scan-locks/:id/force-release` | supervisor+ | Release a lost device's lock | | GET | `/api/scan-locks/reports?eventId=&unreviewed=` | supervisor+ | Sync reports | | POST | `/api/scan-locks/reports/:id/review` | supervisor+ | Mark a report reviewed | How locks work: - One device per scope. A whole-event lock (`sectionId` null) clashes with every section lock on that event. - `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and `deviceId`. It enforces the section on the server, and returns `423 { code: "SCAN_LOCKED" }` when another device holds a lock covering the ticket. How syncing works: - Synced scans never redeem more than is left on a ticket. - Conflicts are recorded in the sync report: `not_found`, `cancelled`, `wrong_scope`, `already_used`, `partly_used` and `consent`. - Check-in messages are sent after the sync. - Locks, releases, force-releases and syncs are written to the admin audit log. ## Waitlist Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines. | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity | | GET | `/api/waitlist/mine` | user+ | My active entries | | DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) | | GET | `/api/waitlist/claim/:token` | public | What a claim link offers | | GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event | | POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit | | POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` | | POST | `/api/waitlist/:id/remove` | supervisor+ | Take someone off | How offers work: - Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks. - Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit. - A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers. - Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer. - Manual registration and adding places to an existing booking are now checked against stock too. ## Parental consent Youth events (`requiresParentalConsent`, plus `consentText`, `consentCollectMedical`, `consentCollectEmergency` and `consentCollectPhoto` on the event) need every main place named with a `dateOfBirth`. Attendees under 18 on the start date also need `parentName`, `parentEmail` and `parentPhone`. Changing `consentText` bumps `consentTextVersion`. | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/consent/:token` | public | What the parent sees: attendee, event, consent text, fields to fill in | | POST | `/api/consent/:token` | public | `{ decision: "give"\|"decline", signedName, isGuardian: true, medical?, emergency?, photoConsent? }` | | GET | `/api/consent/events/:eventId` | staff+ | Consent status per attendee, with counts | | POST | `/api/consent/requests/:id/resend` | user+ | Re-send one request with a new link (registrant or staff) | | POST | `/api/consent/events/:eventId/resend-pending` | supervisor+ | Re-send every pending request | | POST | `/api/consent/events/:eventId/rerequest` | supervisor+ | New requests for consent given on older text, and minors with none | | GET | `/api/consent/events/:eventId/medical` | supervisor+ | Decrypted medical & emergency list (audited as `medical_list_viewed`) | How requests work: - Requests go to the parent by both email and WhatsApp, whatever anyone's notification preference. - Links are random tokens; only an HMAC is stored. They expire when the event ends. - The answer stores the signed name, time, IP, device, and the exact text and version shown. - Medical and emergency details are AES-256-GCM encrypted. A daily job (09:00, `CONSENT_JOBS_ENABLED=false` to disable) sends reminders (day 2, then every 3 days) and deletes them `consent_medical_retention_days` (default 30) after the event. Check-in rules: - `GET /api/tickets/scan-preview/:qrCode` returns `consentStatus` (`not_required`, `missing`, `pending`, `given` or `declined`). - `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given. - Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`. ## Coupons | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/api/coupons/validate` | optional | Check a code against a basket (`{ code, eventId, options, forUserId? }`) | | GET | `/api/coupons` | supervisor+ | List codes with use counts and total discount | | POST | `/api/coupons` | supervisor+ | Create a code | | GET | `/api/coupons/:id` | supervisor+ | One code with its uses | | PUT | `/api/coupons/:id` | supervisor+ | Edit or turn off a code | | GET | `/api/coupons/redemptions` | supervisor+ | Uses for the Discounts report (`eventIds`, `from`, `to`) | - **Types:** `percent` (value 1–100), `fixed` (rand amount), `comp` (free). - **Scope:** `eventIds` (none = every event) and `options: [{ eventOptionId, variantId? }]` (none = the whole registration). - **Restrictions:** `userIds`, `restrictRoles` and `restrictDomain`. A match on any one is enough, and restricted codes need a known account. - **Limits:** `validFrom`, `validUntil`, `maxUses`, `maxUsesPerUser` (default 1) and `minQuantity`. - **Early-bird:** `stackEarlyBird: false` means the better price wins; `true` discounts the early-bird price. - **Locked amount:** the discount is locked when applied. Editing items recalculates percentage and comp codes, but fixed codes keep their amount. Cancelling releases the use. Options and variants also accept an optional `staffPrice`, charged automatically to Staff and above when it's lower than the normal price. ## 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, Yoco secrets) are stored AES-256-GCM encrypted. Yoco secrets are never returned by `/api/settings/all` and can't be written through `PUT /api/settings`; use `/api/yoco` below. For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root. | 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. | ## Yoco Managed from Admin → Site Settings → Yoco. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value. | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/yoco/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL | | PUT | `/api/yoco/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value | | GET | `/api/yoco/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` | | POST | `/api/yoco/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns | | DELETE | `/api/yoco/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one | ### 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=` on the running backend.*