Appearance
WL API (white-label event API)
The WL API is the Laravel application at /var/www/togoactive-development/wl-event/frontend-api-wl-development (GitHub: tga-v3-wl-api, private). It is the single backend for every public white-label event site rendered by the Next.js WL frontend: one deployment serves all events, resolved by domain at request time. It reads and writes the shared TogoActive production MySQL database, bridges into the Togoparts (TGP) platform database for auth, leaderboards and fundraising totals, drives Stripe hosted-checkout payments for registrations / donations / merchandise, renders and sends all participant-facing email, and runs three scheduled jobs (automation emails, donation recalculation, achievement notifications). It also serves several endpoints that are consumed by the admin app, not the public site (email preview/test-send, automation test-send, internal recalc bridges).
Related docs: ./01-architecture-overview.md · ./05-wl-frontend.md · ./03-admin-backend-api.md · ./07-legacy-app-and-tgp-integration.md · ./08-event-setup-guide.md
1. Purpose & role in the platform
The v3 platform splits what the old wl-production monolith did per-event into three cooperating apps:
| App | Serves | Talks to |
|---|---|---|
| WL frontend (Next.js) | Public event sites (landing, registration, profiles, leaderboard, donations) | WL API only |
| WL API (this app) | JSON API for the WL frontend + a handful of admin-app bridges | Shared TogoActive DB (mysql), Togoparts DB (mysql_tgp), Stripe, SMTP |
| Admin backend/frontend | Event setup and operations | Shared TogoActive DB directly; calls WL API for email preview/test-send, automation tests, recalc |
Key design decisions visible throughout the codebase:
- One deployment, many events. Nothing is baked per event. Every request carries an
eventIdin its path, and the frontend discovers that id via the domain resolver (section 2). Adding an event requires zero WL API deploys. - Production parity. Most controllers/services are explicit ports of wl-production repositories (
PaymentRepository,DonationRepository::calculateDonationData,RegisterController,StripeService, etc.), with the parity notes documented inline in the source. Where the WL API deviates it is usually to fix a production bug (e.g. team-donation tax details, legacy cid lookup). - All routes live under
/api/v1in a single, heavily commented file:routes/api.php(310 lines). If you want to know why an endpoint exists, read the comment above its route first. - Three throttle tiers, defined in
app/Providers/RouteServiceProvider.phpand applied so that every route ends up with exactly one limiter (declaring the default inroutes/api.phprather than in the Kernelapimiddleware group prevents a "tightest-wins" double-throttle):throttle:api— 60/min per IP/user. The default floor for writes, auth and sensitive endpoints.throttle:public-read— 300/min (tunable viaPUBLIC_READ_RATE_LIMIT). Idempotent public GETs opt up to this; a single WL page-load fans out into several GETs and the 60/min default was being exhausted by one visitor (see Gotchas).throttle:otp— 3/min. Email-OTP send/verify opts down to this to prevent inbox flooding and code brute-force.
Repo layout (the parts that matter)
routes/api.php # the whole surface, heavily commented — read this first
app/Providers/RouteServiceProvider.php # api / public-read / otp rate limiters
app/Http/Controllers/Api/V1/ # one controller per surface (Auth, Event, Participants,
# Leaderboard, Merchandise, Upgrade, DonationCheckout,
# CheckoutSession, StripeWebhook, ResolveEvent, …)
app/Http/Controllers/Api/V1/Internal/ # WL_INTERNAL_TOKEN bridges (AutomationTest, TeamRecalc)
app/Services/ # business logic (section 9)
app/Support/ # FrontendUrl, TgpChallenge, LegacyCrypt, AuthV2Jwt,
# ProfileUrlResolver
app/Console/Commands/ # AutomationCron, DonationRecalcCron, AchievementNotifyCron
app/Console/Kernel.php # the schedule
app/Domain/Automation/ # EligibilityResolver + NonProdRecipientFilter
config/database.php # mysql (TogoActive) + mysql_tgp (Togoparts)
resources/views/emails/ # branded layout + block partials the renderer composes2. How a request finds its event
There is no per-event configuration in the WL frontend build. The chain is:
- A visitor hits
https://some-event-domain.com/.... The Next.js middleware / server components callGET /api/v1/resolve-event?host=<host>on every navigation. ResolveEventController(app/Http/Controllers/Api/V1/ResolveEventController.php) normalizes the host — lowercase, strip:port, strip leadingwww.— and looks it up in the admin-managedevent_domainstable, requiringstatus = 'active'(i.e. the admin has fully verified the domain).- On a hit it returns
{ success, event_id, domain }withCache-Control: public, max-age=60, so a newly activated domain goes live within a minute. On a miss it returns 404 and the frontend renders its "event not connected with this domain" page. - The frontend then calls the per-event endpoints (
events/{eventId},events/{eventId}/page/{slug}, …) with the resolved id.
Because it is hit on every navigation, resolve-event opts out of throttle:api and uses throttle:public-read (see Gotchas for the incident that forced this).
The reverse direction — building URLs back to the site — is app/Support/FrontendUrl.php. Stripe success_url/cancel_url and link-bearing emails must point at the domain the user is actually browsing, not the global env('FRONTEND_URL') (which on staging is the staging IP). FrontendUrl::forEvent($request, $eventId) resolves in order:
- The request's
Origin/Refererhost — accepted only if it is an activeevent_domainsrow for this event, or equals the configured fallback host (allowlist check; can't be abused as an open redirect). - The event's active custom domain from
event_domains(ashttps://domain). env('FRONTEND_URL')/env('APP_URL')fallback.
FrontendUrl::validateOrFallback() is the event-less variant used by the OAuth cb parameter (any active event domain is accepted). Rule: never build a redirect from raw env('FRONTEND_URL') — always go through FrontendUrl.
3. Full endpoint reference
All paths below are prefixed /api/v1. "public-read" = 300/min limiter; unmarked public routes use the default api 60/min limiter. Source: routes/api.php.
Resolver, internal bridges, webhook
| Method | Path | Purpose | Auth / throttle |
|---|---|---|---|
| GET | resolve-event | Host → event id via event_domains (status=active). Hit on every navigation. | public / public-read |
| POST | internal/automation/test | Admin "Send test" for an automation rule; resolves real eligible data, sends to a test address. | WL_INTERNAL_TOKEN (checked in controller) |
| POST | internal/events/{eventId}/teams/{teamId}/recalc-target | Rebuild one team's cached TGP leaderboard rows (target/raised/donors/qualified). | WL_INTERNAL_TOKEN |
| POST | internal/events/{eventId}/recalc-targets | Same, for every team in the event. | WL_INTERNAL_TOKEN |
| POST | internal/events/{eventId}/recalc-individuals | Rebuild every registered individual's row (/calculate/individuals parity). | WL_INTERNAL_TOKEN |
| POST | webhooks/stripe?event_id=N | Stripe webhook. Signature verified against the event's webhook_secret; event_id query scopes the credential lookup. | Stripe signature |
Checkout & donations
| Method | Path | Purpose | Auth / throttle |
|---|---|---|---|
| POST | events/{eventId}/checkout-session | Create a Stripe hosted Checkout Session for a payment row (registration-flow donation, merch, upgrade). | public (payment ownership validated) |
| GET | events/{eventId}/checkout-session/{sessionId} | Verify a session after Stripe redirects back; also the webhook fallback that promotes the payment to successful. | public |
| POST | events/{eventId}/donations/checkout | Standalone donation: one call writes payments + payment_details + donations (+ team_split rows + tax_deduction_details) and returns the Stripe hosted URL. | public (guest donors allowed; optional bearer identifies donor) |
| GET | events/{eventId}/donations/success/{token} | Decrypt a donation success token → success-page payload (TxnID, amount, tax details, share text). | public |
Email designer bridges (called by the ADMIN app)
| Method | Path | Purpose | Auth / throttle |
|---|---|---|---|
| POST | email-templates/preview | Render a draft template + sample data → HTML for the admin's live preview pane. | api |
| POST | email-templates/test-send | Render the draft with REAL eligible user data (latest participant/donor/team via TestEmailDataResolver) and deliver to an admin-provided test address only. | api |
Public-read group (page-load traffic, 300/min)
Idempotent endpoints the WL frontend hits on every page-load/navigation, grouped under throttle:public-read. One quirk: POST feedback also lives in this group (it is a guest-facing form submit, not sensitive).
| Method | Path (events/{eventId}/…) | Purpose |
|---|---|---|
| GET | (root) events/{eventId} | Public event configuration consumed by Next.js layout/SSR: appearance/theme, header config, mode (default_mode/sessionalmode/donation_mode), all 7 date fields, isLive, registrationStatus (`inactive |
| GET | stats | Aggregate raised / distance / participants — powers the header totals row; cheap aggregates, hit on most page loads. |
| GET | page/{slug} | Published page-builder page by slug (/[slug] and landing routes). |
| GET | page-by-type/{type} | Same shape by type enum — system pages (/user/[id], /individuals/donate/[id], …) where slug isn't deterministic. |
| GET | pages-by-type/{type} | All published pages of a type — powers admin profile-page dropdowns. |
| GET | page-by-pattern?pattern=… | URL-pattern lookup for the WL dynamic resolver (e.g. /fundraiser/[id]). |
| GET | meta/{pageType} · meta-tokens/{pageType} | Resolved SEO/OG meta + token map for Next.js generateMetadata() (via MetaResolver). |
| GET | faq · landing-faq | Full FAQ feed (events_faqs) and the short landing-page FAQ (legacy landing_page table) — two separate systems. |
| GET | highlight-tokens | Resolved {{token}} map for the (optional) logged-in viewer; used by MANUAL Highlight Carousel slides (API-fed highlights are resolved server-side in highlights()). Returns blanks when no valid token is sent. |
| GET | avatars | Admin-curated avatar sets for the profile-picture picker (DB-backed event_avatars). |
| POST | feedback | "Get Help" form → email to events.email; guests allowed, optional bearer identifies a participant. |
| GET | host | Host card (name/avatar/message). |
| GET | recent-donations · donors | Recent-donors feed and full donor listing. |
| GET | participants · participants/{userId} | Participant listing and profile detail. |
| GET | teams/{teamId} | Team profile detail. |
| GET | leaderboard · leaderboard/team/{teamId}/members · leaderboard/highlights | Leaderboard data, team member drill-down, highlight slides. |
| GET | donation-recipient/{type}/{id} · donation-config | Donate-page recipient card and event donation config. |
| GET | achievements · achievements/{achievementId}/winners | Achievements gallery + the "X unlocked" winners modal. |
| GET | default-messages | event_default_message blob (admin Page Builder iframe preview parity). |
| GET | fundraiser-config | event_fundraiser config → Update Goals modal presets. |
| GET | registration-form | Form Builder feed, including the allow_re_registration / allow_early_registration gate flags from registration_setup. |
| GET | teams | Team selector for the multi-step registration flow. |
| GET | rewards | Merchandise catalog for the merch step (price/finalize are writes and stay on the default limiter). |
Validators, merchandise, e-pledge (default api throttle)
| Method | Path | Purpose |
|---|---|---|
| GET | events/{eventId}/epledge/{variant} | E-pledge PNG (desktop 1200×630 / mobile 1080×1920), GD-rendered by EpledgeImageService. |
| POST | events/{eventId}/teams/check-name | Live team-name availability. |
| POST | events/{eventId}/validate-referral · GET …/referral-users | Referral code validation + typeahead search of event participants. |
| POST | events/{eventId}/validate-coupon | Coupon validation. |
| POST | events/{eventId}/rewards/price | Server-side authoritative cart pricing (client never computes the payable amount). |
| POST | events/{eventId}/registration/finalize-merchandise | Persist the merch selection (user_rewards + payment_details) and update the payment. |
Auth (participant flow)
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | auth/register | Step-1 partial register: creates user (or reuses the authed one) + event_users + a free payments row (amount 0). Donation answers come later. | public / optional bearer |
| POST | auth/account-register | Account-only sign-up (login/register pages). Creates the shared Togoparts account + local user and signs in; never enrolls in an event. | public |
| PUT | auth/registration/finalize-donation | Step-2/3: persist donation & qualification answers, set payment amount, re-issue success token. | sanctum |
| GET | auth/registration-result/{token} | Decrypt a registration success token → success-page payload. Shareable/bookmarkable. | public |
| POST | auth/login | Password login against togoparts.users.passwd (see section 8). | public |
| POST | auth/forgot-password · auth/reset-password | Password reset flow. | public |
| POST | auth/check-email · auth/check-username | Live uniqueness checks. | public |
| GET/POST | auth/oauth/init · auth/oauth/callback (GET+POST) | Legacy Togoparts OAuth bridge (Apple / Facebook / email on togoparts.com): signature handshake + auth_t lookup in TGP user_auth_tokens. | public |
| GET | auth/v2/google · auth/v2/google/callback · auth/v2/apple · auth/v2/apple/callback | v2 JWT bridge to togoparts auth_v2.php: provider-parametric, HS256 JWT with shared HMAC secret, verified locally (app/Support/AuthV2Jwt.php), no DB round-trip. | public |
Sanctum-protected group (auth:sanctum)
| Method | Path | Purpose |
|---|---|---|
| GET / POST | auth/me · auth/logout | Session info / token revoke. |
| GET | events/{eventId}/default-coupon | Returning-participant loyalty coupon pre-fill for the merch step. |
| POST | events/{eventId}/upgrade/start · …/upgrade/finalize | Post-registration "Buy Merch" flow (section 5). |
| GET/POST/PUT/DELETE | users/me/addresses[/{id}] | Delivery-address CRUD (TGP user_addresses; triggers lazy TGP provisioning). |
| PUT | events/{eventId}/user-goals | Owner-only fundraising + distance goal update. |
| GET | events/{eventId}/fundraiser-dashboard | /fundraiser page payload. |
| PUT | events/{eventId}/user-fundraising-message · …/teams/{teamId}/fundraising-message | Pledge-message edits (individual / captain). |
| GET | events/{eventId}/me/registration-summary | Viewer's address + purchased merch (Whiteboard "signed up" card). |
| GET/PUT | events/{eventId}/me/profile | /profile personal info. |
| POST | events/{eventId}/me/avatar · …/me/avatar/select | Upload / pick-curated avatar → writes tgp.users.profile_img. |
| POST | events/{eventId}/teams/{teamId}/avatar[/select] | Team picture, captain-only → teams.team_avatar_img. |
| POST | events/{eventId}/teams/{teamId}/members | Join team ("Join This Team" CTA) — membership row, aggregate recompute, team_join mail to captain. |
| POST | events/{eventId}/teams | Create team (registered viewer becomes leader). |
| POST | events/{eventId}/me/security/password | Change password. |
| POST | events/{eventId}/me/security/email/send-otp · …/verify | Email-change OTP — throttle:otp 3/min. |
| GET/POST | events/{eventId}/me/team · …/leave · …/remove-member | Team panel; leave; captain removes member (donation reallocation + team_remove mail via TeamMembershipService). |
| GET | events/{eventId}/me/achievements | Viewer's achievements block. |
| GET/POST | events/{eventId}/me/strava · …/unlink | Strava connection status / unlink. |
| GET | events/{eventId}/me/ebib · me/ecert · me/ecert/status | E-Bib and E-Cert downloads. |
4. Registration flow end-to-end
The registration flow is deliberately split so that a user who abandons mid-flow is still counted as registered. Source: AuthController::register() (routes to POST auth/register), finalizeDonation(), plus MerchandiseController::finalize() and CheckoutSessionController.
Gates first. GET events/{eventId}/registration-form returns the admin's Form Builder feed plus two gate flags from registration_setup:
allow_re_registration— QA escape hatch: bypasses duplicate-email / already-registered checks so the same user can replay the full flow (the existingevent_usersrow is reused, never duplicated).allow_early_registration— soft launch: bypasses the "opens on <date>" gate before the window opens; the closed-window (ended) gate still applies.GET events/{eventId}reportsregistrationStatusas one ofinactive | not_started | open | ended, so the frontend can distinguish "opens later" from "ended".
Step 1 — POST auth/register (partial register). In one DB transaction:
- User: reuse the Sanctum-authenticated viewer if a bearer token was sent; otherwise create a
usersrow (fullname/username/email/password). A registration test-mode gate (config('registration.allow_live')off → only emails containing theregtestmarker are accepted) applies to anonymous sign-ups only. - TGP provisioning: new sign-ups immediately get a backing Togoparts account via
TgpAccountProvisioner::ensureForTgaUser()(non-fatal — a failure is logged and backfilled lazily on first address save). event_users: idempotent upsert on(event_id, user_id). New rows get a sequential per-eventbib(00001,MAX+1) protected by a MySQL advisory lock (GET_LOCK("wl_bib_assign_{eventId}")) so concurrent registrations can't collide, plus a random 40-chartoken. Address fields are denormalized from the chosenuser_addressesrow.event_user_meta: replaced (delete + insert) with the mapped form answers.- Fundraising seed: if the event has a TGP cid (
TgpChallenge::cid) and the user atgp_userid,FundraisingMessageService::seedForUser()inserts/fills the participant'schallenge_donation_leaderboardrow (individual rows alwaysteamid = 0) with the event's default fundraising description — idempotent, only fills empty columns. - Avatar auto-assignment:
AuthController::autoAssignAvatar()gives participants without a photo a gender-matched avatar from the DB-backedevent_avatarssets (eventgender_neutralwhen it has >5 images, else the sharedanimalset), written totgp.users.profile_img. - Team:
teamparam is eithernew:<name>(create team + owner membership + team avatar +seedForTeamTGP description) or a numeric id (join). Membership is idempotent — prior membership for this event is cleared first. - Payment: a free
paymentsrow —payment_type='registration',payment_method='Free', amount 0,status='successful', placeholderpayment_intent/transaction_idoffree_<ts>_<rand>. Re-registration reuses the existing free row.
After commit: an encrypted success token (payload: event/event_user/payment ids, txn ref, amount, with_donation, qualified flag) is issued; the registration-success email is sent (idempotent via an event_user_meta mail_sent_at marker); a welcome email fires for brand-new accounts; TeamMailService::notify() fires team_created (owner) or team_join (to the captain) — idempotent per (event_user, team). Response includes a fresh Sanctum token (new sign-ups only) and redirect: /registration/success/<token>.
Step 2 — PUT auth/registration/finalize-donation (sanctum). Persists donation/qualification answers into event_user_meta (with a server-side anonymity scrub of the display name), verifies the payment_id belongs to the caller, and updates the payment: donation amount > 0 → status='pending' (Stripe hasn't charged yet — marking successful here would break the verify path's guard); no donation → successful. Returns a re-encrypted success token with the final totals.
Merchandise step (feature-flagged) — POST rewards/price computes the authoritative total server-side (MerchandiseService, port of the monolith's price/coupon repositories); POST registration/finalize-merchandise writes user_rewards + payment_details line items and updates the payment.
Payment — if anything is payable, the frontend calls POST events/{eventId}/checkout-session with the payment id, amount, payment_type and the success token; the API validates the payment belongs to the event, builds success_url = {FrontendUrl}/payment/process/{success_token}/{CHECKOUT_SESSION_ID} and redirects the browser to the Stripe-hosted URL. The success token is deliberately not put in Stripe metadata (the encrypted blob can exceed Stripe's 500-char metadata value limit); verify() recovers it from the session's success_url path segment.
Settlement — webhook or verify, whichever lands first (both idempotent):
POST webhooks/stripe?event_id=Nhandlespayment_intent.succeeded/charge.succeeded/checkout.session.completed: flips the payment tosuccessful, replaces thefree_*placeholder with a friendly transaction id ({event-slug}{payment_id}, e.g.ampup108817), marksuser_rewardspaid, runsDonationLeaderboardService::recalculateForPayment(), and sends the donation-receiver, donation-confirmation and merch-confirmation emails (all idempotent per payment).GET checkout-session/{sessionId}(the/payment/processpage) performs the same promotion when Stripe sayspaidbut our row is still pending or still carries afree_*placeholder intent — the fallback for environments Stripe can't reach (local dev; staging behind a firewall).
Success page — GET auth/registration-result/{token} decrypts the token server-side and enriches it with username/fullname/tgp_userid/country so the page survives refresh, bookmarking and sharing without exposing PII in the URL payload.
5. Upgrade ("Buy Merch") flow
UpgradeController (auth-scoped) lets an already-registered participant buy more merchandise later. It deliberately reuses the registration merch machinery; the only upgrade-specific logic is the already-registered gate, the remaining-quantity cap, a fresh payment row per attempt, and event_users.has_upgraded.
POST events/{eventId}/upgrade/start— checks the master enable on the admin'sevent_upgradeconfig (403 with redirect when off, before any side effects), verifies the caller is registered (409 →/registrationotherwise), resolves buyer country (query → registration country → Singapore), returns the remaining-quantity catalog (MerchandiseService::listRewardsForUpgrade, capped by what the user already bought) plus the page copy/intro (which prefers the event's Rewards → Instructions,events_meta.reward_instructions), and inserts a placeholderpaymentsrow withpayment_type='upgrade'and refupgrade_<ts>_<rand>.POST events/{eventId}/upgrade/finalize— server-side re-price, writesuser_rewards/payment_details, stampsevent_users.has_upgraded, and (if paid) hands off tocheckout-sessionexactly like the merch step. Theupgradepayment type gets itemized Stripe line items (section 7).- Success page + email reuse
registrationResultandsendMerchConfirmationEmail(which renders the admin-editableupgradetemplate).
The original registration payment row is never mutated by an upgrade.
6. Donation flow
Standalone donations (donate to an individual, a team, or the host/campaign) are one POST: events/{eventId}/donations/checkout (DonationCheckoutController::initiate, a port of wl-production's DonationController::storeDonationDataAPI). Guests may donate — a bearer token, when present, only attributes the donor user_id.
In one transaction it writes:
| Table | Row |
|---|---|
payments | Parent row, payment_type='donation', status='pending', currency from the event_donation config (default SGD). |
payment_details | One link row for the donation amount. |
donations | One row to the primary recipient. type is individual or team; a host donation is stored as type='individual' (the host is still a user on the receiver side, matching production). Anonymous donors get display_name='anonymous'. |
donations (team_split) | For team donations: one extra row per member, amount divided evenly (distributeAmountAmongUsers parity). |
tax_deduction_details | Only when the donor ticks tax-claim. Keyed by payment_id, so it works for every recipient type — the old event_user_meta stash was gated to individual/host recipients and silently dropped team-donation tax details ("Not tax deductible" on success pages). security_type = NRIC/UEN, security_id = the number, security_name = person/company name. |
It then creates the Stripe session (payment_type='donation', redirect URLs via FrontendUrl) and returns the hosted URL plus an encrypted success token.
After payment (webhook or verify fallback — both paths):
DonationLeaderboardService::recalculateForPayment(paymentId)recomputes fundraising totals on the TGP tables. Totals are always recomputed from scratch (SUM of successful donations), never incremented, so the double execution of webhook + verify is harmless. Individual total = donations of typeregistration|individual|team_splitto that user; team total = donations of typeregistration|individual|teamto the team and its members; team↔member changes cascade both ways. Writeschallenge_donation_leaderboard.raised_fund(individual,teamid=0) andchallenge_team_leaderboard.raised_fund(keyed bytga_team_id).- Donor confirmation + recipient notification emails fire (idempotent per payment).
Success page: GET donations/success/{token} decrypts the token and returns TxnID, amount, recipient name/profile URL, tax details (read back from tax_deduction_details by payment id), and the share text — sourced from the admin's per-recipient event_donation.recipient_messages.{type}.share_text with self-donation detection (donating to your own campaign yields the first-person "Join me!" copy instead of the third-person variant).
Reads that surface these totals: ParticipantsApiController reads individual raised amounts from challenge_donation_leaderboard and team raised amounts via its getTeamRaisedMap() helper reading challenge_team_leaderboard.raised_fund — never by re-summing donations at request time.
7. Payments & Stripe
app/Services/StripeService.php — hosted Checkout Session architecture (Stripe owns the card UI; the API creates a session and redirects).
Credentials are per event. getEventCredentials(eventId) resolves the configuration row payment_gateways → gateways.stripe → a payment_gateway_config library row containing public_key / secret_key / webhook_secret / mode / currency / statement_descriptor. Every Stripe call (setApiKeyForEvent) and the webhook signature check goes through this, so different events can settle into different Stripe accounts.
Test-credential fallback. An event with no gateway configured falls back to the shared Togoparts TEST credential: defaultTestCredentialId() uses env('DEFAULT_STRIPE_TEST_CREDENTIAL_ID') if set, else the payment_gateway_config row named togopart-test with mode='test'. When even that is missing, failPaymentUnavailable() logs the technical reason and throws a participant-safe message that references the event by name ("Online payment isn't available for “X” right now…") — no ids or table names leak to the UI.
Line items per payment_type (createCheckoutSession):
donation— aDonationline at the amount; a registration-context donation (thewithRegistrationLineflag, set only byCheckoutSessionController::create) additionally showsRegistrationat $0.00 first. A standalone donation shows the Donation line alone — no confusing "Registration $0.00" row.registration— a singleRegistrationline.upgrade(covers both the registration merch step and the upgrade flow) — itemized per SKU from the persistedpayment_details+user_rewards(name · size · customization text, qty, full unit price), with any coupon surfaced as a one-time Stripe Coupon discount ("Coupon CODE −$x"). An authoritative-amount guard reconciles the itemized total minus discount against the charge amount to the cent; any mismatch (or a coupon-create failure) falls back to a singleRewardsline at the exact amount — a display nicety can never change what is charged.
Other behaviors: paynow is added as a payment method when the event currency is SGD; the event name goes on the PaymentIntent description (merchant dashboard/receipts) while statement_descriptor is sanitized to 22 alphanumeric chars; every session is logged to stripe_session_logs (best-effort — a logging failure never blocks payment); client_reference_id carries the payment id so webhook/verify can find the row even without metadata.
8. Auth
Three ways in, all converging on a local Sanctum bearer token minted against the WL API's users table:
Password login (AuthController::login) authenticates against the Togoparts account DB — mysql_tgp.users.passwd — using cryptPasswordVerify(), a constant-time mirror of the monolith's custom_password_verify() built on PHP crypt() (which transparently handles both bcrypt $2y$ and older crypt formats present in that column). The WL API never writes any password column on TGP — passwords are owned by Togoparts. On success upsertLocalUserFromTgp() finds-or-creates the local user (matched by tgp_userid, then email, then username — the same precedence as the OAuth bridge, so password and OAuth logins resolve to the same row).
Legacy OAuth bridge (OAuthController) — kept for the providers v2 doesn't cover (Apple/Facebook/email on togoparts.com): init mints a token + sha1 signature, stashes a random callback token in cache (10-min TTL) with the validated cb URL, and 302s to togoparts.com/user/auth/callback/; the callback resolves auth_t against TGP's user_auth_tokens table, mirrors the user locally, and bounces back to the cb with ?oauth=success&token=<sanctum>.
v2 JWT bridge (AuthV2Controller, Google + Apple) — replaces the handshake with a self-contained HS256 JWT signed by togoparts auth_v2.php (issuer togoparts-auth-v2, user claims embedded), verified locally by app/Support/AuthV2Jwt.php with a shared HMAC secret — no signature handshake, no auth_t DB lookup.
Account provisioning in the other direction — TgpAccountProvisioner (section 9) makes sure every WL-originated account also exists on Togoparts, and LegacyCrypt (app/Support/LegacyCrypt.php) populates the legacy NOT-NULL pwd/salt columns (AES-256-CBC with the monolith's exact key — do not change it) when it creates TGP rows.
Once a bearer token exists, all /me/*, upgrade, address, team and fundraiser endpoints use standard auth:sanctum.
9. Services catalogue (app/Services)
| Service | What it does / who calls it |
|---|---|
| AchievementNotificationService | Sends achievement-unlock emails for v3 events (id ≥ 49) that opted in via the achievement_email_active config key. Reads challenge_achievement_winners rows with notified=0 (written by the old-admin assignment cron), sends, flips notified=1. The shared flag is the dedup contract with the old togoparts cron, which keeps events < 49. Email only — the old cron's TGP in-app push is out of scope. Called by achievements:notify. |
| AvatarService | Resolves selectable/auto-assignable avatars from the DB-backed event_avatars table (replaces static config/avatars.php). Event-specific categories (male/female/gender_neutral) exist only when the admin uploads; global categories (male_classic/female_classic/animal) are shared event_id NULL rows. randomForRegistration(): event gender_neutral set when it has >5 images, else animal. Called by registration auto-assign, team creation, and the avatar picker endpoints. |
| DonationLeaderboardService | The write side of fundraising totals: recalculateForPayment() recomputes challenge_donation_leaderboard / challenge_team_leaderboard raised_fund from scratch after a paid payment (idempotent; port of production DonationRepository::calculateDonationData). Called from the Stripe webhook, the verify fallback, and team membership changes. |
| EmailBrandingService | Loads per-event email branding (configuration key email_branding) for the branded blade layout. Defaults: empty header (no logo is auto-pulled — a header band appears only once the admin sets logos) and a togoparts footer (brand logo + socials mirroring the public site footer), so a fresh event ships a working footer. |
| EmailLogger | Writes every outgoing email to the legacy mail_logs table (to_email, event_id, is_sent, maildata JSON). Failures are swallowed + logged so logging can never block a send. The admin Email Logs page reads this. |
| EmailTemplateRenderer | Renders a per-event email template (admin Setup → Email Templates, configuration key email_templates) into the branded layout: block partials, token substitution, condition gating. Sender resolution: template/branding from_address → global MAIL_FROM_* → no-reply@togoparts.com. Live sends pass requireEvent=true and abort (logged as a failed row) rather than silently render as env('WL_EVENT_ID'); preview/test keep the env fallback. interactive=true wraps blocks in click-to-edit divs for the admin preview pane only. Called by every Mailable, TeamMailService, AchievementNotificationService, the preview/test-send endpoints. |
| EpledgeImageService | GD-rendered e-pledge PNG (desktop 1200×630, mobile 1080×1920): per-event background from event_images.donation_pledge_*, circular avatar from tgp.users.profile_img (with the uploads/ → TogoActive CDN branch), auto-fit message text. Called by FundraiserController::epledge. |
| FundraisingMessageService | Single source of truth for pledge messages. Individual messages live on mysql_tgp.challenge_donation_leaderboard (fundraising_description/fundraising_share_text), team messages on challenge_team_leaderboard.team_description. seedForUser/seedForTeam are the idempotent registration/team-create seeds (only fill empty columns; individual rows always teamid=0); writeForUser/writeForTeam back the /fundraiser edits; resolve* read with token substitution and template fallback. |
| MerchandiseService | Server-side merch logic: catalog (rewards table, country/currency resolution, core vs add-on split), authoritative pricing and coupon validation (ports of the monolith's repositories), remaining-quantity catalog for upgrades. The client never computes the payable amount. |
| MetaResolver | SEO meta cascade: page-level event_pages.seo → event_meta_templates per page type → event-wide social_seo → hardcoded event-name fallback, then token substitution (missing tokens collapse to empty, never literal {{x}}). Output shape matches Next.js generateMetadata(). Note: per-page meta is wired to registration + upgrade pages only; system pages use the type templates. |
| StripeService | See section 7. |
| TeamDonationRecalcService | The /calculate/* parity engine: rebuilds a team's cached TGP rows (target = SUM of member targets, raised = SUM of qualifying donations, donors, total_users, qualified = any member qualified) and, recursively, each member's individual row. Used by the internal recalc endpoints and the donations:recalc sweep. Resolves cid through TgpChallenge::cid() so legacy-mapped events work. |
| TeamMailService | Team lifecycle emails from registration: creator → team_created; a join → team_join to the captain. Rendered through EmailTemplateRenderer (admin-authored content). Idempotent per (event_user, team) via an event_user_meta marker (team_mail_sent:{teamId}); fully self-guarding so it can never break registration. |
| TeamMembershipService | Team create / remove-member / leave. Production fund rule on removal: the member's team_split donation rows are deleted and folded back onto the team leader's donation rows sharing the same payment_detail_id; member and team aggregates are then recomputed and the team_remove mail fires. |
| TestEmailDataResolver | For "Send Test Email": per mail-type lookup of the most recently active REAL user/donor/team so the test renders with production-shaped data; the real recipient's address is never used (caller forces the test address). Empty result → platform sample data. |
| TgpAccountProvisioner | Ensures a TGA user has a backing Togoparts account (togoparts.users + user_profile, mirroring wl-production's RegisterController) and stamps users.tgp_userid. Links to an existing TGP row by email/username instead of duplicating. Called eagerly at registration (non-fatal) and lazily by the address endpoints. Without it, TGP-keyed features (addresses, avatars, leaderboard rows, share URLs) fail with "No togoparts profile linked". |
Support classes (app/Support): FrontendUrl and TgpChallenge are described in sections 2 and 10; AuthV2Jwt and LegacyCrypt in section 8; ProfileUrlResolver builds user/team/donate profile URLs honoring the admin's url_pattern on published event_pages (defaults /user/{id}, /team/{id}, /individuals/donate/{id}, /team/donate/{id}), memoized per type.
10. Scheduled jobs & internal bridges
app/Console/Kernel.php schedules three commands, all withoutOverlapping()->runInBackground():
| Command | Schedule | What it does |
|---|---|---|
automation:run (AutomationCron) | hourly | Evaluates enabled event_automation_rules for events whose registration window is open and leaderboard hasn't ended. Each rule's own trigger_every_cycle (every_hour/6h/12h/daily) gate against last_run_at decides if it fires this tick. Resolves eligible users (App\Domain\Automation\EligibilityResolver), sends AutomationMail, updates last_run_at/last_user_count for the admin UI. Non-prod environments pre-filter recipients through NonProdRecipientFilter. Flags: --event, --rule, --dry-run, --force. |
donations:recalc (DonationRecalcCron) | every 10 min | The drift-corrector sweep for active v3 events (present in event_domains status=active, window still open). Replaces two old-admin crons whose /api/calculate/* URL-hits 404 on v3 domains: money via TeamDonationRecalcService (individuals pass, then teams non-recursively), then ranks via the TGP stored procedures CALL UpdateDonationRanks(cid, excludedHosts) and CALL UpdateTeamDonationRanks(cid). Host TGP user ids (from the event_host_tgp_user_id config key and events_meta.host_config) are excluded from individual ranking, and force-set to rank 99999 afterwards. Flags: --event, --dry-run. |
achievements:notify (AchievementNotifyCron) | every 5 min | Batch-sends achievement-unlock emails via AchievementNotificationService (v3 events ≥ 49, opt-in, notified flag dedup with the legacy cron). Flags: --event, --limit (default 200). |
Event-driven recalc (webhook / verify / team changes) remains the instant path; the sweep only corrects drift, and it is idempotent so overlap with the legacy crons is harmless.
Internal bridges are plain POST routes authenticated inside the controller by comparing a token body param (or bearer) against env('WL_INTERNAL_TOKEN') with hash_equals — an empty env token rejects everything:
internal/automation/test(Internal/AutomationTestController) — called by the admin backend's AutomationRuleController when an admin clicks "Send test". Resolves the rule withtestMode: true(relaxed time gates), merges sample fallback values over zero/empty real values so previews never read "$0 of $0", respects the non-prod allowlist.internal/events/{id}/teams/{teamId}/recalc-target,internal/events/{id}/recalc-targets,internal/events/{id}/recalc-individuals(Internal/TeamRecalcController) — admin → WL recalc bridge (v3 parity with wl-production's/calculate/teamand/calculate/individuals). These WRITE to production TGP tables, hence never exposed to end-user traffic. Flagsdry_runandrecursive(team scope only).
11. Database footprint
Two connections in config/database.php:
mysql(default) — the shared TogoActive production DB (DigitalOcean managed; databasetogoactive, stage varianttogoactive-stage).strict => true.mysql_tgp— the Togoparts production DB.strict => false(legacy schema). Holds auth, Strava/challenge activity data, and the donation leaderboard cache tables plus their rank stored procedures.
Main tables by connection (not exhaustive — read-only page/config tables omitted where obvious):
| Connection | Table | R/W | Used for |
|---|---|---|---|
| mysql | event_domains | R | Domain → event resolution; also defines the "active v3 events" set for crons. |
| mysql | events, events_dates, events_meta, configuration, event_default_message, event_pages, event_meta_templates, social_seo, events_faqs, landing_page | R | Event config, dates, meta/SEO, page builder, FAQ, default copy. (configuration also gets host/cid keys read by crons.) |
| mysql | registration_setup | R | Registration gates. (The form schema and qualification config are configuration keys — event_registration / event_qualification — not tables.) |
| mysql | users | R/W | Local accounts; tgp_userid link stamped by provisioner/login. Uses fullname (not name). |
| mysql | event_users | R/W | Enrolments: bib, token, denormalized address, referral_code, has_upgraded, is_paid_user. |
| mysql | event_user_meta | R/W | Form answers, donation answers, idempotency markers (mail_sent_at, team_mail_sent:{teamId}). key/value columns (NOT meta_key/meta_value). |
| mysql | teams, team_users | R/W | Teams, membership (is_owner), team avatars. |
| mysql | payments, payment_details, donations | R/W | All money movement; donations.type ∈ registration/individual/team/team_split (host stored as individual). |
| mysql | tax_deduction_details | R/W | Donor tax claims, keyed by payment_id (all recipient types). |
| mysql | rewards, coupons, user_rewards | R/W | Merch catalog, coupon validation, purchased line items (payment_status flipped by webhook/verify). |
| mysql | stripe_session_logs, mail_logs | W | Debug/audit trails (best-effort writes). |
| mysql | event_avatars, event_images, event_integrations, event_automation_rules | R (+W on event_automation_rules.last_run_at) | Avatars, image slots, analytics tags, automation rules. (Upgrade config is the configuration key event_upgrade, not a table.) |
| mysql_tgp | users, user_profile | R/W | TGP accounts: password verify (R of passwd), provisioning (W incl. legacy pwd/salt via LegacyCrypt), profile_img avatar writes. |
| mysql_tgp | user_auth_tokens | R | Legacy OAuth auth_t resolution. |
| mysql_tgp | user_addresses | R/W | Delivery addresses (AddressPickerModal). |
| mysql_tgp | challenge_donation_leaderboard | R/W | Per-participant raised/target/rank/pledge message. Individual rows keyed (cid, userid, teamid=0) — teamid is never NULL. |
| mysql_tgp | challenge_team_leaderboard | R/W | Per-team aggregates keyed (cid, tga_team_id), incl. denormalized team_name and team_description. |
| mysql_tgp | challenge_achievement_winners, challenge_leaderboard | R/W, R | Achievement notify flag; distance data. |
| mysql_tgp | stored procedures UpdateDonationRanks, UpdateTeamDonationRanks | CALL | Rank rebuild from raised_fund (see Gotchas for the NULL argument trap). |
The event ↔ challenge link is app/Support/TgpChallenge.php: configuration key TGP_CHALLENGE_ID (written by the new admin UI) with a fallback to the legacy events_meta TGP_CHALLENGE_ID — events mapped only the old way (e.g. event 49 / TOGOSG61 → cid 119; event 46 → cid 116) resolve via the fallback. Memoized per request. No cid ⇒ the event silently has no fundraising surface (registration skips seeding, sweeps skip it).
12. Deployment & environments
The WL API runs on two servers against one shared database:
| Dev/staging | Production | |
|---|---|---|
| Host | 128.199.72.46 (this box) | wl-api.togoparts.com (178.128.113.107) |
| Code | working tree, pushed to GitHub tga-v3-wl-api (private, main) | deployed from the same repo, separately |
| DB | shared DigitalOcean MySQL (togoactive / togoactive-stage) + shared TGP DB | same |
Consequences that bite in practice:
- Config/DB changes appear everywhere instantly (both servers read the same rows:
configuration,event_domains, templates, gateways). Code changes do not — they must be pushed and deployed per server. - The admin app depends on the PRODUCTION WL API. The admin frontend's email preview/test-send and the donation picker call
VITE_WL_API_URL, which points atwl-api.togoparts.com— not the local dev instance. A stale deploy on the prod box therefore produces bugs that only reproduce in the admin preview while the code "works locally". If a preview-only bug appears, check the prod WL API deploy first. - Environment-sensitive values (
WL_INTERNAL_TOKEN, Stripe fallback credential id,FRONTEND_URL,PUBLIC_READ_RATE_LIMIT, mail from-address,QUALIFIED_MIN_DONATION, registrationallow_live/test marker) live in each server's.envand can differ between the two. - Non-prod safety nets: registration test-mode (emails must contain the
regtestmarker unlessregistration.allow_liveis on) andNonProdRecipientFilter(automation/test emails only to allowlisted recipients). - Serve the API with nginx + php-fpm in anything resembling production.
php artisan serveis single-threaded and was a root cause of intermittent site-wide failures (see Gotchas).
13. Gotchas
- Rate limiting history ("event not connected" incident). The resolver plus the public GET fan-out on every navigation exhausted the default 60/min
apilimit with a single active visitor; 429s fromresolve-eventrendered the "event not connected with this domain" page even though the domain was active. Fixes now in place: thepublic-read300/min limiter for idempotent GETs, one-limiter-per-route wiring (no double-throttle), a 60sCache-Controlon the resolver, and a TTL cache on the frontend side. The single-threadedphp artisan serveamplified this; the real cure is nginx + php-fpm. - Pass
'', never NULL, to the rank stored procedures.UpdateDonationRanks(cid, excluded)filters withFIND_IN_SET(userid, excluded);FIND_IN_SET(x, NULL)is NULL, which filters out every row and ranks nobody.DonationRecalcCronbuilds the excluded-hosts list as a comma-joined string that is''when empty. Related: the procedure skips excluded-but-qualified hosts rather than demoting them, so the cron force-updates their rank to 99999 afterwards. challenge_donation_leaderboardindividual rows useteamid = 0, never NULL. Every reader and writer (FundraisingMessageService,DonationLeaderboardService,TeamDonationRecalcService, participant reads) filters onteamid = 0; a NULL row is invisible to all of them.- Never build redirects from
env('FRONTEND_URL'). UseFrontendUrl::forEvent()(orvalidateOrFallback()for event-less flows). The env value is a last-resort fallback and on staging points at the staging IP — Stripe would strand paying users there. - Success tokens don't fit in Stripe metadata. The encrypted blob can exceed Stripe's 500-char metadata-value limit and Stripe rejects the whole session. The token travels in the
success_urlpath andverify()regex-extracts it back out (metadata read kept only for legacy sessions). - Team raised totals come from
challenge_team_leaderboard.raised_fund(viaParticipantsApiController::getTeamRaisedMap), not from summing direct-to-teamdonationsrows — the direct sum misses member-attributed donations and reads too low. - The webhook is not guaranteed to arrive; the verify endpoint is the real settlement path in dev. Both run the same idempotent promotion (status flip,
free_*placeholder replacement,user_rewardsflip, leaderboard recompute, emails). When adding post-payment side effects, add them to both paths and make them idempotent (the codebase convention isevent_user_metamarker keys). event_user_metauseskey/valuecolumns (notmeta_key/meta_value), both NOT NULL with no default — inserting the wrong column names or NULLs throws SQLSTATE[23000].- Host donations are stored as
type='individual'indonations(the host is a user on the receiver side);tax_deduction_detailskeyed bypayment_idis what makes tax data work for team donations — don't "optimize" it back onto the recipient'sevent_user_meta. LegacyCrypt's key and cipher are copied verbatim from the monolith. Changing them makes every existingtogoparts.users.pwdrow undecryptable for the legacy services that still read it. Likewise, the WL API never writes password columns during login — TGP owns passwords.- cid lookup must check both stores.
TgpChallenge::cid()readsconfigurationthen falls back to legacyevents_meta; reading onlyconfiguration(as wl-production's helper did) makes legacy-mapped events show "Raised $0 of $0" everywhere. - Free-registration rows are
status='successful'withfree_*placeholders at insert time, which is why the verify fallback also re-runs when the intent is still a placeholder — a plainstatus !== 'successful'guard misses them. - Bib assignment needs the advisory lock. The sequential per-event bib is MAX+1 inside
GET_LOCK("wl_bib_assign_{eventId}"); bypassing the lock in a new insert path reintroduces duplicate bibs under concurrency.