Appearance
End-to-End Flows
This document stitches the per-system docs together: for each major platform journey it walks through, step by step, which actor does what — the Admin SPA, the Admin API, the shared TogoActive DB, the WL API, the WL public site, Stripe, the Togoparts (TGP) DB, and the legacy cron fleet. Read 01-architecture-overview.md first; this doc assumes you know the five systems and the shared-database model. Endpoint and table details are deliberately summarized — follow the links into the sibling docs for the full references.
Related docs: 01-architecture-overview.md · 02-admin-frontend.md · 03-admin-backend-api.md · 04-wl-api.md · 05-wl-frontend.md · 06-database-schema.md · 07-legacy-app-and-tgp-integration.md · 08-event-setup-guide.md · 09-developer-guide.md
Flow index:
| # | Flow | One line |
|---|---|---|
| 1 | Event lifecycle | Create in admin → seed → configure → publish → served by domain → date-gated end |
| 2 | Admin edit → public site | Save → shared DB → cache flush ping → 60 s TTL backstop; iframe previews of unsaved state |
| 3 | Registration + payment | Partial register → merch → donation → Stripe → dual idempotent settlement → success token + email |
| 4 | Upgrade ("Buy Merch") | Registered participant buys more merch via the same machinery, payment_type='upgrade' |
| 5 | Donation | One-POST checkout → Stripe → TGP leaderboard recompute → tax details → 10-min recalc cron |
| 6 | Activity → leaderboard | Legacy-owned: Strava ingest → Stage 3 sync → TGP rank procedures → WL renders |
| 7 | Achievements | v3 defines, legacy awards, notification split at event 49, voucher coupons in email |
| 8 | Emails & automation | Template hierarchy → branded renderer → cross-server admin preview → hourly automation cron |
| 9 | Custom domains | TXT + DNS verification → deploy-agent vhost/SSL → resolve-event serves it |
| 10 | Port users | 5-step wizard → dry-run → background job with port_run_id stamps → revert |
Actor legend used below:
| Actor | Meaning |
|---|---|
| Admin SPA | React admin console (tga-v3-admin-web) |
| Admin API | Laravel admin backend (tga-v3-admin-api) |
| DB | Shared TogoActive production MySQL |
| WL API | Laravel public API (tga-v3-wl-api) |
| WL site | Next.js public site (tga-v3-wl-web) |
| Stripe | Stripe hosted Checkout + webhooks |
| TGP DB | Togoparts production DB (mysql_tgp) — Strava logs, leaderboards, rank stored procedures |
| Legacy | Old production app at /var/www/togoactive and its cron fleet |
Conventions that repeat in every flow (memorize these once):
- The DB is shared and live. A row written by any actor is instantly visible to every other actor. Code, by contrast, deploys per server — "works here, broken there" is almost always a stale deploy, not stale data.
- Post-payment side effects must be idempotent and present in BOTH settlement paths (Stripe webhook and the verify-polling return page). The codebase convention for one-shot effects is a marker row in
event_user_meta. - Admin saves flush the WL cache (
WlCache::flush→ WL/api/revalidate); the WL site's 60 s server-side TTL is the backstop when the ping doesn't land. - URLs back to the public site always go through
FrontendUrl::forEvent()— never rawenv('FRONTEND_URL')— so Stripe redirects and emails stay correct on custom domains. - TGP-side fundraising rows have shape rules: individual
challenge_donation_leaderboardrows useteamid = 0(never NULL); rank stored procedures take''(never NULL) for empty exclusion lists.
1. Event lifecycle: create → configure → publish → live → ended
- Admin SPA → Admin API:
POST /api/v1/events(EventController::store) with name, slug,mode(default_mode/sessionalmode/donation_mode), and initial dates. - Admin API → DB: creates the
eventsrow plus satellite rows, then runs two seeders:FeatureService::seedDefaults($eventId, $mode)— writes the mode's default feature flags intoevent_feature_settings. The mode decides which admin sections and WL behaviors are on by default; admins can override per feature later (see 03-admin-backend-api.md).EventSeedService::seed($eventId)— clones the template event (event 49 / TOGOSG61 by default;config('togoactive.template_event_id')): thewl_menuJSON inevents_meta, and every builder page (blocks + SEO +url_pattern), createdpublishedand ready. Menu items reference pages bypage::<slug>route strings, so copying menu and pages independently keeps them wired. The seed is idempotent (skips existing slugs, never marks two pages active for one system type) and best-effort — a failure logs a warning but never blocks event creation.
- Admin SPA → Admin API → DB: the admin configures the event — dates, images/branding, registration form, rewards, coupons, payment gateway, integrations, pages, menu, emails, domain. The complete menu-by-menu checklist is 08-event-setup-guide.md. Every content save also triggers flow 2's cache flush.
- Admin SPA → Admin API: publish (
EventController::publish) validates viaPublishEventRequest, setsevents.event_status = 1andvisibility, and flushes the WL cache. Unpublishing is the same call withpublish_status='draft'. - WL site: once an
event_domainsrow for the event isstatus='active'(flow 9), every request to that host resolves through the WL API'sresolve-eventendpoint and the site serves the event. No WL deploy is needed to launch an event — one WL deployment serves all events (04-wl-api.md §2). - Event end is date-driven, not a status flip. The WL API's
GET events/{id}computes gate fields fromevents_dates:registrationStatus ∈ inactive | not_started | open | ended— the WL registration routes render the correct "opens on…" vs "has ended" message;allow_early_registration(soft launch) andallow_re_registration(QA) selectively bypass the gates (04-wl-api.md §4).leaderboardStarted— date-gated leaderboard/verification views; per-event "tester" participants can preview them early.
- WL site header: for a visitor who is not registered on an ended event, the JOIN NOW CTA is replaced by a disabled EVENT ENDED button (
components/Header.tsx); ended events also drop the mobile "Raise Funds" quick action. - Crons self-limit by the same dates: the WL API's
donations:recalcandautomation:runonly process events whose window is still open, and legacy per-event crons carryendDateTimegates inconfig/schedule.php.
Admin SPA ──POST /events──► Admin API ──seed features + menu/pages──► DB
Admin SPA ──configure (08)─► Admin API ──writes + WlCache::flush────► DB → WL site
Admin SPA ──publish────────► Admin API ──event_status=1────────────► DB
Visitor ───host header─────► WL site ──resolve-event──► WL API ──event_domains──► DB
(date gates: not_started → open → ended
header CTA: JOIN NOW → EVENT ENDED)2. Admin edit → public site propagation (and the preview path)
The save path
- Admin SPA → Admin API: any event-content save — pages, appearance, menu, rewards, FAQ, donation/fundraiser config, default messages, feature settings, leaderboard config.
- Admin API → DB: writes the shared DB. At this instant the data is already "live" for anything reading the DB directly (including the legacy app).
- Admin API → WL site:
WlCache::flush($eventId)(app/Support/WlCache.php) POSTs{"eventId": N}to the WL site's/api/revalidateroute with thex-revalidate-secretheader (configservices.wl.revalidate_url/revalidate_secret). - WL site: the revalidate handler (
app/api/revalidate/route.ts) verifies the secret and purges the in-memory server cache — per event when aneventIdis given (keys carry the event id in their second segment:config:<id>,page:<id>:<slug>,meta:<id>:<pageType>:<qs>), or the whole cache otherwise. - WL site: server renders read through
lib/serverCache.ts— a process-global in-memory TTL cache (hung offglobalThisso route handlers and server components share one instance). Host resolution, event config, builder pages and SEO meta are each cached 60 s (TTL.host/config/page/meta = 60_000). Expired entries are retained as stale fallbacks: when the WL API is momentarily unreachable the site serves slightly-stale data instead of the "event not connected" page. - Hard-reload bypass: a browser hard reload sends
Cache-Control: no-cache;lib/event.ts::isHardReload()detects it and passesbypass: true— the fresh-cache read is skipped (forcing a re-fetch) but the result is still written back, so normal navigation stays cached.
Why an edit can take up to 60 s to appear
The revalidate ping is best-effort. The TTL is the backstop whenever the ping doesn't land:
- the secret or URL is misconfigured on the admin API side;
- the WL server is briefly unreachable;
- you are looking at a WL instance the ping doesn't target (e.g. the local dev site while the admin API pings production);
- the Edge middleware's own small host cache (a separate runtime, deliberately not shared with the Node cache) hasn't expired yet.
A hard browser reload always forces fresh data; otherwise wait out the minute.
The preview path (UNSAVED state, real renderer)
- Page Builder: the admin's block editor embeds the WL site's
/builder-previewpage in an iframe and streams the unsaved editor state overpostMessage— inboundpb-init(blocks/device/selection),pb-blocks,pb-selection,pb-hover,pb-device; outboundpb-ready,pb-block-clicked,pb-block-hover. The iframe runs the realBlockRenderer, so the preview is pixel-identical to production (05-wl-frontend.md). - Form Builder: the same pattern against
/registration-previewwithfb-*messages (tree panel · live preview · settings drawer); an/upgrade-previewsibling backs the Upgrade tab. - Because previews render editor state pushed over postMessage, nothing touches the DB or the cache — the 60 s question only applies after Save.
Admin SPA ──save──► Admin API ──write──► DB
└──WlCache::flush──► WL /api/revalidate ──purge──► serverCache
Visitor ──navigate──► WL site ──(60s TTL, stale-fallback)──► WL API ──► DB
Admin SPA ──iframe /builder-preview ◄──pb-* postMessage──► real BlockRenderer (unsaved)3. Registration + payment
The canonical multi-step journey — 3 to 5 steps depending on feature flags: details → qualification → merchandise → donation → summary (step components in the WL site's app/registration/: RegistrationStep, QualificationStep, MerchandiseStep, DonationStep, SummaryStep, orchestrated by RegistrationFlow). Full API detail: 04-wl-api.md §4.
- Gates. WL site → WL API:
GET events/{id}/registration-form(Form Builder schema + theallow_re_registration/allow_early_registrationflags) andGET events/{id}(registrationStatus). These decide whether the flow renders at all and with which message. - Step 1 — partial register. WL site → WL API:
POST auth/register. The flow is deliberately split so an abandoner after step 1 still counts as registered. In one DB transaction the WL API:- creates the
usersrow (or reuses the Sanctum-authenticated viewer); - provisions a backing Togoparts account via
TgpAccountProvisioner— writes TGP DBusers/user_profileand stampsusers.tgp_userid(non-fatal; backfilled lazily on first address save). Without it, every TGP-keyed feature later fails with "No togoparts profile linked"; - upserts
event_users— sequential per-eventbibassigned under a MySQL advisory lock (GET_LOCK("wl_bib_assign_{eventId}")), random 40-char token, denormalized delivery address; - replaces the
event_user_metaform answers; - seeds the donation leaderboard: if the event resolves a TGP cid (
TgpChallenge::cid()—configurationkey, falling back to legacyevents_meta.TGP_CHALLENGE_ID),FundraisingMessageService::seedForUser()inserts the participant'schallenge_donation_leaderboardrow on the TGP DB — individual rows alwaysteamid = 0; - auto-assigns an avatar for photo-less participants — a gender-matched image from the DB-backed
event_avatarssets, written to TGPusers.profile_img; - creates or joins the team when asked (
new:<name>vs numeric id; idempotent membership); - inserts a $0
paymentsrow —payment_type='registration',payment_method='Free',status='successful', placeholderfree_*intent/transaction ids. After commit: an encrypted success token is issued; registration-success, welcome and team emails fire (each idempotent viaevent_user_metamarkers); the response carries a fresh Sanctum token (new sign-ups) and the/registration/success/<token>redirect.
- creates the
- Merchandise step (feature-flagged). WL site → WL API:
POST rewards/price— the server computes the authoritative cart total and validates coupons (MerchandiseService; the client never computes the payable amount) — thenPOST registration/finalize-merchandisewritesuser_rewards+payment_detailsline items and updates the payment. - Donation / qualification step. WL site → WL API:
PUT auth/registration/finalize-donation(sanctum) — persists donation and qualification answers intoevent_user_meta(server-side anonymity scrub), verifies payment ownership, and sets the payment: donation > 0 →status='pending'(Stripe hasn't charged yet); nothing payable →successful. Returns a re-encrypted success token with final totals. - Checkout. If anything is payable, WL site → WL API:
POST events/{id}/checkout-session:StripeServiceresolves per-event credentials:configurationpayment_gateways→ apayment_gateway_configlibrary row (keys, webhook secret, mode, currency). An unconfigured event silently falls back to the shared TEST credentialtogopart-test— replacing it is a launch-checklist item.success_url = {FrontendUrl::forEvent(...)}/payment/process/{success_token}/{CHECKOUT_SESSION_ID}— the token travels in the URL path because the encrypted blob can exceed Stripe's 500-char metadata limit.- Line items depend on
payment_type: registration-context donations showRegistration $0.00+Donation; merch/upgrade payments are itemised per SKU with the coupon as a Stripe discount (an authoritative-amount guard means display can never change the charge). - The browser is redirected to the Stripe-hosted checkout.
- Settlement — two racing paths, both idempotent. Every post-payment side effect must exist in BOTH:
- Stripe → WL API:
POST webhooks/stripe?event_id=N(signature verified against the event's own webhook secret) flips the payment tosuccessful, replaces thefree_*placeholder with a friendly transaction id ({event-slug}{payment_id}), marksuser_rewardspaid, runsDonationLeaderboardService::recalculateForPayment()(flow 5), and sends the confirmation emails. - WL site → WL API: the
/payment/process/{token}/{sessionId}return page pollsGET checkout-session/{sessionId}, which performs the same promotion when Stripe reportspaidbut our row is still pending (or still carries afree_*placeholder). This verify path is the only settlement path in environments Stripe's webhook can't reach (local dev, firewalled staging).
- Stripe → WL API:
- Success page. WL site → WL API:
GET auth/registration-result/{token}decrypts the token server-side and enriches it — the page survives refresh, bookmarking and sharing with no PII in the URL. - Email. All mails render through
EmailTemplateRendererwith the event's branding (flow 8), get logged tomail_logs, and are idempotent per payment/marker.
4. Upgrade ("Buy Merch")
A post-registration merchandise purchase that deliberately reuses the registration merch machinery (04-wl-api.md §5).
- WL site:
/upgradesits behind the participant auth gate. The header's "Buy Merch" button and the route itself are enabled by the admin's Form Builder Upgrade tab, which persists to theevent_upgradeconfiguration row (step_count.enablemaster switch). - WL site → WL API:
POST events/{id}/upgrade/start—- checks the master enable before any side effects (403 + redirect when off);
- verifies the caller is actually registered (409 →
/registrationotherwise); - returns the remaining-quantity catalog (
MerchandiseService::listRewardsForUpgrade, capped by what the user already bought) plus the page intro copy (top priority: Rewards → Instructions,events_meta.reward_instructions); - inserts a fresh placeholder
paymentsrow withpayment_type='upgrade'and aupgrade_<ts>_<rand>ref. The original registration payment row is never mutated.
- WL site: renders the exact same
MerchandiseStepcomponent used in registration, with quantities capped. - WL site → WL API:
POST events/{id}/upgrade/finalize— server-side re-price, writesuser_rewards/payment_details, stampsevent_users.has_upgraded. - Payment: a payable cart hands off to the same
checkout-session→ Stripe → webhook/verify settlement as flow 3 (upgrade payments get the itemised per-SKU line items). A $0 cart (fully couponed) settles free with no Stripe hop. - WL site: redirects to
/registration/success/{token}?upgrade=1— the shared success page renders its upgrade variant; the confirmation email renders the admin-editableupgradetemplate.
Participant ──"Buy Merch"──► WL site /upgrade (auth gate + event_upgrade enable)
│ POST upgrade/start ───► WL API: registered? remaining-qty catalog,
│ intro copy, NEW payments row (type='upgrade')
│ (same MerchandiseStep UI as registration)
│ POST upgrade/finalize ─► re-price, user_rewards, has_upgraded
│ payable? ── yes ──► checkout-session ► Stripe ► webhook/verify (flow 3)
│ no ───► settle free
▼
/registration/success/{token}?upgrade=1 + 'upgrade' template email5. Donation (visitor donates to an individual / team / host)
Guests may donate — a bearer token, when present, only attributes the donor. Full detail: 04-wl-api.md §6.
- WL site: the donate page (
/individuals/donate/{id},/team/donate/{id}) loads the recipient card and donation config from the WL API (donation-recipient,donation-config). - WL site → WL API:
POST events/{id}/donations/checkout— one call, one transaction, writes:payments— parent row,payment_type='donation',status='pending';payment_details— the donation amount line;donations— one row to the primary recipient (type ∈ individual | team; a host donation is stored astype='individual'); anonymous donors getdisplay_name='anonymous';donationsteam_splitrows — team donations add one row per member, the amount divided evenly;tax_deduction_details— when the donor claims tax, keyed bypayment_idso it works for every recipient type (the old per-recipient stash silently dropped team-donation tax details). Then creates the Stripe session (redirect URLs viaFrontendUrl) and returns the hosted URL + an encrypted success token.
- Stripe → WL API (webhook) and/or WL site → WL API (verify) — the same idempotent settlement pair as flow 3.
- WL API → TGP DB:
DonationLeaderboardService::recalculateForPayment()— totals are recomputed from scratch (SUM over successful donations), never incremented, which is exactly why the webhook+verify double execution is harmless:- individual:
challenge_donation_leaderboard.raised_fund, rows keyed(cid, userid, teamid=0); - team:
challenge_team_leaderboard.raised_fund, keyed(cid, tga_team_id); team↔member changes cascade both ways. Team "raised" headers/lists on the WL site readchallenge_team_leaderboard— never a re-sum ofdonations(the direct sum misses member-attributed rows and reads too low).
- individual:
- WL API: donor confirmation + recipient notification emails (idempotent per payment).
- WL site → WL API:
GET donations/success/{token}— TxnID, amount, recipient profile link, tax details (read back fromtax_deduction_details), and per-recipient share text with self-donation detection. - Drift correction — WL API cron:
donations:recalcevery 10 minutes sweeps active v3 events (inevent_domainsstatus=active, window open): money viaTeamDonationRecalcService(individuals pass, then teams), then ranks by calling the TGP stored proceduresUpdateDonationRanks(cid, excluded)andUpdateTeamDonationRanks(cid). Two hard rules: pass'', never NULL, for an empty excluded list (NULL makesFIND_IN_SETfilter out every row — nobody gets ranked), and exclude the host's TGP user ids, force-setting their rank to 99999 afterwards. Legacy events (< 49) get the equivalent from the legacyUpdateDonationRank(3 min) + calculate-URL crons — whose URLs 404 on v3 events (07-legacy-app-and-tgp-integration.md §5).
Visitor ──► WL site ──POST donations/checkout──► WL API
payments + payment_details + donations(+team_split)
+ tax_deduction_details ──► DB ──► Stripe hosted URL
Stripe ──webhook──► WL API ◄──verify── WL site (/payment/process)
└─► recalculateForPayment ──► TGP DB
(raised_fund: individual teamid=0, team by tga_team_id)
every 10 min: donations:recalc ──► money sweep + CALL UpdateDonationRanks(cid, '')6. Activity → leaderboard (legacy-owned)
The activity pipeline is entirely owned by the legacy cron fleet — v3 only reads the results. Full cron reference: 07-legacy-app-and-tgp-integration.md §4/§7.
- Participant connects Strava — the WL profile page reports and unlinks the connection (
me/strava), while the Strava OAuth/token machinery itself lives on the TGP side. - Strava → TGP DB: activities arrive as unsynced rows in
challenge_activities_log— the raw ingest queue. - Legacy — Stage 2 ingest:
LeaderboardStage2Sync(every 3 minutes, global) reads unsynced rows, fetches full activity data from the Strava API, and writes the activities tables. Astrava:drain-backlogutility exists for historical catch-up. - Legacy — hygiene sweeps (every 2 hours, minute-staggered):
DuplicateActivityandDuplicateActivityCronUrlHitcatch duplicates;SuspiciousActivityrunsCALL GetSuspiciousActivitieson the TGP DB to flag anomalous entries. - Admin review: flagged/suspicious/duplicate activities surface in the Admin SPA under Operations → Activity Manager (
ActivitiesPage+activity-managertable/drawer components) for manual review and correction. - Legacy — Stage 3 leaderboard sync (per event, minute-staggered by design): the
LeaderBoardSync*Stage3family — outdoor, indoor, team, group, images and seasonal variants — writes the TGA/TGP leaderboard tables. - Legacy → TGP DB: each Stage 3 run then calls the rank stored procedures —
UpdateRanks,UpdateHoursRanks,UpdateTeamRanks,UpdateDonationRanks,UpdateSeasonalRanks,UpdateGroupDonationRanks— which recompute rank columns in place. - WL site → WL API: the leaderboard endpoints (
leaderboard, team-member drill-down,leaderboard/highlights) read the ranked tables and render them. Date gating applies (leaderboardStarted); per-event "tester" participants can preview early.
Two rules when touching this machinery: every legacy scheduled entry carries the isDbHigh DB-CPU skip-guard, and every cron has a unique minute stagger — new entries must respect both (07 §8).
7. Achievements
Definition is v3; assignment (awarding) runs in the legacy app; notification is split by event id.
- Define (Admin). Admin SPA → Admin API → DB: the admin creates achievement groups and achievements with conditions. Conditions persist in
achievement_cron_setup(one row per automated achievement,achievement_type='automation', result-date policy) andachievement_cron_condition(subconditions:distance/activity/donation/purchase/sign_up, each with optional date windows and anadditional_optionsJSON blob). Image slots upload like any other image type. - Award (Legacy). The legacy generic
AchievementMasterCron(minutes 2,12..52) selects every event whose registration has started and whose results date isn't more than an hour past — v3 events included — and feeds each condition intoAchievementUnlockService(V2 for events > 22), which writes winners tochallenge_achievement_winnerson the TGP DB. Older events also have bespoke per-event award crons (Decypher2026,TogoRide2026AchievementCron, …).- ⚠️ The legacy service evaluates condition JSON the v3 admin writes — the key names form a contract. A historical mismatch made donation/purchase/referral conditions award nobody; when adding condition fields in the admin, verify
AchievementUnlockServiceactually reads them. - ⚠️ The bespoke
TogoSg61AchievementCron(event 49) is deliberately disabled in the legacyconfig/schedule.phpwith an explicit do-not-re-enable comment. Re-enabling it would double-assign winners.
- ⚠️ The legacy service evaluates condition JSON the v3 admin writes — the key names form a contract. A historical mismatch made donation/purchase/referral conditions award nobody; when adding condition fields in the admin, verify
- Notify — the
< 49/>= 49split (07 §2). Both engines readchallenge_achievement_winnersrows withnotified = 0and flip the flag after sending — thenotifiedflag is the handshake, so exactly one engine must own each event:- Events < 49 → Legacy
ChallengeNotification: sends the email and POSTs an in-app push to TGP'sapi.php; hard-guarded byachievementEmailsEnabled(): (int) $eventId < 49. - Events >= 49 → WL API
achievements:notify(every 5 minutes; opt-in per event via theachievement_email_activeconfig key): email only, rendered throughEmailTemplateRenderer(achievement_unlocktemplate), links built withFrontendUrl.
- Events < 49 → Legacy
- Voucher-tier achievements (SG61 pattern). When the unlocked achievement is a voucher tier, the WL API's notification path lazily mints a unique single-use coupon (
SG61-XXXXXX;app/Support/AchievementCoupons.php) at email time and renders it as a dashed voucher box; the coupon is redeemable in the merch/upgrade checkout (flows 3–4). - Display (WL). Winners appear on the participant profile's Trophy/Achievements tab (
me/achievements) and in the event-wide achievements gallery (achievements+ per-achievement winners modal).
Admin SPA ──conditions──► DB (achievement_cron_setup + _condition)
Legacy AchievementMasterCron ──evaluate──► challenge_achievement_winners (TGP DB)
< 49: Legacy ChallengeNotification ──email + TGP push──► notified=1
>= 49: WL API achievements:notify ──branded email (+voucher mint)──► notified=1
WL site ──► profile Trophy tab · achievements gallery8. Emails & automation
Template hierarchy and rendering (04-wl-api.md §9)
- Defaults ship in the WL API's
config/email_templates; per-event overrides live in the DB (configurationkeyemail_templates), edited in the admin's Email Designer. - WL API
EmailTemplateRenderercomposes block partials, substitutes{{tokens}}, and applies per-block condition gating. Branding comes fromEmailBrandingServicewith deliberate defaults: an empty header (no logo is auto-pulled — a header band appears only once the admin sets logos) and a togoparts footer, so a fresh event ships working emails. Sender resolution: template/brandingfrom_address→ globalMAIL_FROM_*→no-reply@togoparts.com. Live sends abort (logged as failed) if the event can't be resolved; every send is written tomail_logs, which backs the admin Email Logs page. - Admin preview / test-send crosses servers. The Admin SPA calls
POST email-templates/previewandtest-sendon the PRODUCTION WL API (VITE_WL_API_URL = wl-api.togoparts.com) — not the local dev instance. Preview renders in interactive click-to-edit mode; test-send resolves the most recent REAL participant/donor/team data (TestEmailDataResolver) but only ever delivers to the admin-provided test address. Consequence: a stale deploy on the prod WL API breaks only these admin features while everything else works — the classic preview-only bug signature.
Automation rules
- Admin SPA → Admin API → DB: rules live in
event_automation_rules(audience/eligibility, trigger cadence, template, enabled flag). - "Send test" goes Admin API → WL API:
POST internal/automation/test, authenticated with the sharedWL_INTERNAL_TOKEN; the WL API resolves the rule with relaxed time gates and sample fallbacks so previews never read "$0 of $0". - WL API cron:
automation:run(hourly) evaluates enabled rules for events whose registration window is open; each rule's owntrigger_every_cycle(hourly/6h/12h/daily) gate againstlast_run_atdecides whether it fires this tick. Recipients come fromEligibilityResolver; sends go out asAutomationMail;last_run_at/last_user_countfeed the admin UI. Non-prod environments filter recipients throughNonProdRecipientFilter. - Dedupe is shared with the legacy engine. The legacy automation-mail engine (events 28–48) and the v3 engine both write and dedupe against
trigger_email_logs(md5-of-fields +from_days). The contract assumes single ownership: an event must never have rules configured in BOTH engines, or participants get duplicate mails the dedupe can't catch.
config/email_templates (defaults) Admin SPA (Email Designer)
└── overridden by ──► configuration.email_templates (per event, DB)
│
WL API EmailTemplateRenderer ◄───────┘ branding: empty header, togoparts
(blocks, tokens, conditions) footer, sender no-reply@togoparts.com
│
every Mailable · TeamMailService · achievements:notify · automation:run
Admin preview/test-send ──VITE_WL_API_URL──► PRODUCTION wl-api (stale deploy
here = preview-only bugs)
Admin "Send test" (automation) ──Admin API──WL_INTERNAL_TOKEN──► WL API
automation:run (hourly) ──EligibilityResolver──► AutomationMail
└── dedupe via trigger_email_logs (SHARED with the legacy engine)9. Custom domain provisioning
- Admin SPA → Admin API:
POST events/{id}/domain(DomainController::store). Theevent_domainsrow startsstatus='pending'with a generated TXT challenge — record name_togoactive-verify, valuetogoactive-verify=<random32>. (A bareIP[:port]— dev/testing — can't carry DNS records and jumps straight toactive.) - Admin adds the TXT record at their DNS provider, then Admin SPA → Admin API:
verify-txt— the API does a livedns_get_record("_togoactive-verify.<domain>", DNS_TXT); on an exact value match the row becomesstatus='txt_verified'(ownership proven). DNS propagation can take up to 48 h; the endpoint is retryable. - Admin points the domain's A record at the WL server, then Admin SPA → Admin API:
verify-dns— checks the A record resolves to one of our server IPs; on success the row becomesstatus='active'. - Admin API → WL server:
WlDeployService::syncDomains()POSTs{WL_DEPLOY_URL}/sync-domainswith theWL_DEPLOY_TOKENbearer. The deploy agent on the WL host (re)provisions the nginx vhost + SSL certificate for the current set of active domains. This is best-effort by design: a 409 means a sync is already in progress (the pending run picks up the change — treated as success); any real failure is logged and surfaced as a non-blocking "provisioning may be delayed — retry from the dashboard" warning, and a manualsyncendpoint exists for retries. Deleting a domain triggers the same sync to de-provision. - WL site: within ~60 seconds (the resolver responds with
Cache-Control: max-age=60, and the site's host TTL cache is 60 s)resolve-eventstarts answering for the new host and the site serves the event on the custom domain — no WL build or deploy involved. - Everything that emits URLs follows automatically:
FrontendUrl::forEvent()prefers the request's own (allowlisted) origin, then the event's active custom domain, then the env fallback — so Stripesuccess_url/cancel_urland link-bearing emails always point at the domain the user is actually on. Never build redirects from rawenv('FRONTEND_URL')(04-wl-api.md §2).
Admin SPA ──add domain──► Admin API ──pending + TXT token──► DB
Admin ──DNS TXT──► verify-txt ──► txt_verified
Admin ──DNS A────► verify-dns ──► active ──► WlDeployService ──sync-domains──► WL server
(nginx vhost + SSL)
Visitor ──custom domain──► WL site ──resolve-event (≤60s cache)──► event served
Stripe redirects / email links ──FrontendUrl::forEvent()──► custom domain10. Port users (copy participants between events)
Admin Operations → Port Users; controller PortUserController, engine under the admin backend's app/Services/PortUsers/.
- Admin SPA — a 5-step wizard:
- pick source event(s) (
sourceEvents); - filter the audience (
audiencePreview, with a CSVaudienceExportfor offline checking); - review the schema mapping (
schemaDiff— how source registration fields land on the target event's form); - dry-run;
- execute.
- pick source event(s) (
- Dry-run. Admin SPA → Admin API:
POST events/{id}/port-users/dry-run—PortExecutor::dryRun()simulates the full run and reports would-be creates / skips / conflicts without writing anything. - Execute. Admin SPA → Admin API:
POST events/{id}/port-users/execute— refuses (409) if a run is alreadyrunningfor the event, creates aport_runsrow, and dispatches theExecutePortRunjob. The job has a 30-minute timeout andtries = 1— no auto-retry, because a half-retried port would leave confusing partial state. It behaves identically underQUEUE_CONNECTION=sync(blocks the request) andredis(background). - Job → DB:
PortExecutorcopies each selected participant into the target event, writing oneport_run_itemsrow per user and stamping every row it creates with theport_run_id— the stamp is what makes the run auditable and revertible. The run finishescompleted(with a counts summary) orfailed(with the error captured). - Monitor. Admin SPA polls
runs/{runId}and listsruns(statusesrunning | completed | failed | reverted). - Revert. Admin SPA → Admin API:
POST runs/{runId}/revert—PortReverterdeletes exactly the rows stamped with that run's id and marks the runreverted(409 if the run isn't in a revertible state).
Where each flow can break
| Flow | Common failure | First place to look |
|---|---|---|
| 2 — Propagation | Admin saved but the WL site shows old content for up to a minute | The 60 s serverCache TTL is the backstop when WlCache::flush → /api/revalidate doesn't land: check services.wl.revalidate_url/secret on the admin API, and whether you're viewing a WL instance the ping doesn't target. A hard reload bypasses the cache. |
| 8 — Email preview / donation picker | Feature works in code but is broken only inside the admin UI | Stale deploy on the production WL API (wl-api.togoparts.com) — the Admin SPA's VITE_WL_API_URL points there, not at local dev. Deploy prod wl-api first, then re-test. |
| 3/5/6 — Fundraising & leaderboards | "Raised $0 of $0" everywhere; registration never seeds a leaderboard row; recalc skips the event | Missing TGP cid mapping: TgpChallenge::cid() finds neither the configuration TGP_CHALLENGE_ID key nor the legacy events_meta row. Set the CID field in the admin. |
| 3/4/5 — Payments | Charges land in the wrong (test) Stripe account; real cards fail in live mode | The togopart-test fallback credential was never replaced with the event's real gateway in payment_gateway_config — a mandatory launch-checklist item (01 §7). |
| 5/6 — Ranking | Ranks empty / nobody ranked after a recalc | NULL passed as the excluded-ids parameter to UpdateDonationRanks and friends — FIND_IN_SET(x, NULL) filters out every row. Pass '' for an empty list. |
| 8 — Automation | Participants receive duplicate automation emails | The event has rules in BOTH engines (legacy events 28–48 engine and v3 automation:run) — the shared trigger_email_logs dedupe assumes single ownership. Remove one side. |
| 7 — Achievements | Duplicate awards/notifications on event 49 | Someone re-enabled TogoSg61AchievementCron in the legacy config/schedule.php, or widened achievementEmailsEnabled() past < 49. Both must stay exactly as-is (07 §10). |
| 7 — Achievements | Conditions save fine in the admin but nobody is ever awarded | Condition JSON keys don't match what the legacy AchievementUnlockService reads (the v3-admin ↔ legacy-cron key contract); also check the event window against AchievementMasterCron's date sweep. |
| 3/4/5 — Settlement | Payment succeeded on Stripe but our row stays pending; emails/leaderboard never fire | Webhook unreachable (dev / firewalled env) and the buyer never landed back on /payment/process — the verify endpoint is the real settlement path there. Any new side effect must live in both paths, idempotently. |
| 9 — Custom domains | Domain shows active in admin but the browser gets an SSL error or the default vhost | The WlDeployService sync never provisioned the vhost: check WL_DEPLOY_URL/WL_DEPLOY_TOKEN on the admin API, the deploy-agent logs on the WL server, and retry via the domain sync endpoint. |
| 1 — Lifecycle | A new event's public site is empty or its menu is missing | The best-effort EventSeedService seed failed at creation (admin API logs) — re-run it, it's idempotent. Also confirm the event is published and a domain is active. |
| 2/9 — Resolution | Intermittent "event not connected with this domain" | Historically a rate-limit + single-threaded php artisan serve problem — check the WL API is behind nginx+php-fpm and the public-read limiter is in place (04 §13). |