Skip to content

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:

#FlowOne line
1Event lifecycleCreate in admin → seed → configure → publish → served by domain → date-gated end
2Admin edit → public siteSave → shared DB → cache flush ping → 60 s TTL backstop; iframe previews of unsaved state
3Registration + paymentPartial register → merch → donation → Stripe → dual idempotent settlement → success token + email
4Upgrade ("Buy Merch")Registered participant buys more merch via the same machinery, payment_type='upgrade'
5DonationOne-POST checkout → Stripe → TGP leaderboard recompute → tax details → 10-min recalc cron
6Activity → leaderboardLegacy-owned: Strava ingest → Stage 3 sync → TGP rank procedures → WL renders
7Achievementsv3 defines, legacy awards, notification split at event 49, voucher coupons in email
8Emails & automationTemplate hierarchy → branded renderer → cross-server admin preview → hourly automation cron
9Custom domainsTXT + DNS verification → deploy-agent vhost/SSL → resolve-event serves it
10Port users5-step wizard → dry-run → background job with port_run_id stamps → revert

Actor legend used below:

ActorMeaning
Admin SPAReact admin console (tga-v3-admin-web)
Admin APILaravel admin backend (tga-v3-admin-api)
DBShared TogoActive production MySQL
WL APILaravel public API (tga-v3-wl-api)
WL siteNext.js public site (tga-v3-wl-web)
StripeStripe hosted Checkout + webhooks
TGP DBTogoparts production DB (mysql_tgp) — Strava logs, leaderboards, rank stored procedures
LegacyOld 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 raw env('FRONTEND_URL') — so Stripe redirects and emails stay correct on custom domains.
  • TGP-side fundraising rows have shape rules: individual challenge_donation_leaderboard rows use teamid = 0 (never NULL); rank stored procedures take '' (never NULL) for empty exclusion lists.

1. Event lifecycle: create → configure → publish → live → ended

  1. Admin SPAAdmin API: POST /api/v1/events (EventController::store) with name, slug, mode (default_mode / sessionalmode / donation_mode), and initial dates.
  2. Admin APIDB: creates the events row plus satellite rows, then runs two seeders:
    • FeatureService::seedDefaults($eventId, $mode) — writes the mode's default feature flags into event_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')): the wl_menu JSON in events_meta, and every builder page (blocks + SEO + url_pattern), created published and ready. Menu items reference pages by page::<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.
  3. Admin SPAAdmin APIDB: 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.
  4. Admin SPAAdmin API: publish (EventController::publish) validates via PublishEventRequest, sets events.event_status = 1 and visibility, and flushes the WL cache. Unpublishing is the same call with publish_status='draft'.
  5. WL site: once an event_domains row for the event is status='active' (flow 9), every request to that host resolves through the WL API's resolve-event endpoint 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).
  6. Event end is date-driven, not a status flip. The WL API's GET events/{id} computes gate fields from events_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) and allow_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.
  7. 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.
  8. Crons self-limit by the same dates: the WL API's donations:recalc and automation:run only process events whose window is still open, and legacy per-event crons carry endDateTime gates in config/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

  1. Admin SPAAdmin API: any event-content save — pages, appearance, menu, rewards, FAQ, donation/fundraiser config, default messages, feature settings, leaderboard config.
  2. Admin APIDB: writes the shared DB. At this instant the data is already "live" for anything reading the DB directly (including the legacy app).
  3. Admin APIWL site: WlCache::flush($eventId) (app/Support/WlCache.php) POSTs {"eventId": N} to the WL site's /api/revalidate route with the x-revalidate-secret header (config services.wl.revalidate_url / revalidate_secret).
  4. WL site: the revalidate handler (app/api/revalidate/route.ts) verifies the secret and purges the in-memory server cache — per event when an eventId is given (keys carry the event id in their second segment: config:<id>, page:<id>:<slug>, meta:<id>:<pageType>:<qs>), or the whole cache otherwise.
  5. WL site: server renders read through lib/serverCache.ts — a process-global in-memory TTL cache (hung off globalThis so 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.
  6. Hard-reload bypass: a browser hard reload sends Cache-Control: no-cache; lib/event.ts::isHardReload() detects it and passes bypass: 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-preview page in an iframe and streams the unsaved editor state over postMessage — inbound pb-init (blocks/device/selection), pb-blocks, pb-selection, pb-hover, pb-device; outbound pb-ready, pb-block-clicked, pb-block-hover. The iframe runs the real BlockRenderer, so the preview is pixel-identical to production (05-wl-frontend.md).
  • Form Builder: the same pattern against /registration-preview with fb-* messages (tree panel · live preview · settings drawer); an /upgrade-preview sibling 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.

  1. Gates. WL siteWL API: GET events/{id}/registration-form (Form Builder schema + the allow_re_registration / allow_early_registration flags) and GET events/{id} (registrationStatus). These decide whether the flow renders at all and with which message.
  2. Step 1 — partial register. WL siteWL 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:
    1. creates the users row (or reuses the Sanctum-authenticated viewer);
    2. provisions a backing Togoparts account via TgpAccountProvisioner — writes TGP DB users/user_profile and stamps users.tgp_userid (non-fatal; backfilled lazily on first address save). Without it, every TGP-keyed feature later fails with "No togoparts profile linked";
    3. upserts event_users — sequential per-event bib assigned under a MySQL advisory lock (GET_LOCK("wl_bib_assign_{eventId}")), random 40-char token, denormalized delivery address;
    4. replaces the event_user_meta form answers;
    5. seeds the donation leaderboard: if the event resolves a TGP cid (TgpChallenge::cid()configuration key, falling back to legacy events_meta.TGP_CHALLENGE_ID), FundraisingMessageService::seedForUser() inserts the participant's challenge_donation_leaderboard row on the TGP DB — individual rows always teamid = 0;
    6. auto-assigns an avatar for photo-less participants — a gender-matched image from the DB-backed event_avatars sets, written to TGP users.profile_img;
    7. creates or joins the team when asked (new:<name> vs numeric id; idempotent membership);
    8. inserts a $0 payments rowpayment_type='registration', payment_method='Free', status='successful', placeholder free_* intent/transaction ids. After commit: an encrypted success token is issued; registration-success, welcome and team emails fire (each idempotent via event_user_meta markers); the response carries a fresh Sanctum token (new sign-ups) and the /registration/success/<token> redirect.
  3. Merchandise step (feature-flagged). WL siteWL API: POST rewards/price — the server computes the authoritative cart total and validates coupons (MerchandiseService; the client never computes the payable amount) — then POST registration/finalize-merchandise writes user_rewards + payment_details line items and updates the payment.
  4. Donation / qualification step. WL siteWL API: PUT auth/registration/finalize-donation (sanctum) — persists donation and qualification answers into event_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.
  5. Checkout. If anything is payable, WL siteWL API: POST events/{id}/checkout-session:
    • StripeService resolves per-event credentials: configuration payment_gateways → a payment_gateway_config library row (keys, webhook secret, mode, currency). An unconfigured event silently falls back to the shared TEST credential togopart-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 show Registration $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.
  6. Settlement — two racing paths, both idempotent. Every post-payment side effect must exist in BOTH:
    • StripeWL API: POST webhooks/stripe?event_id=N (signature verified against the event's own webhook secret) flips the payment to successful, replaces the free_* placeholder with a friendly transaction id ({event-slug}{payment_id}), marks user_rewards paid, runs DonationLeaderboardService::recalculateForPayment() (flow 5), and sends the confirmation emails.
    • WL siteWL API: the /payment/process/{token}/{sessionId} return page polls GET checkout-session/{sessionId}, which performs the same promotion when Stripe reports paid but our row is still pending (or still carries a free_* placeholder). This verify path is the only settlement path in environments Stripe's webhook can't reach (local dev, firewalled staging).
  7. Success page. WL siteWL 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.
  8. Email. All mails render through EmailTemplateRenderer with the event's branding (flow 8), get logged to mail_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).

  1. WL site: /upgrade sits 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 the event_upgrade configuration row (step_count.enable master switch).
  2. WL siteWL 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 → /registration otherwise);
    • 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 payments row with payment_type='upgrade' and a upgrade_<ts>_<rand> ref. The original registration payment row is never mutated.
  3. WL site: renders the exact same MerchandiseStep component used in registration, with quantities capped.
  4. WL siteWL API: POST events/{id}/upgrade/finalize — server-side re-price, writes user_rewards/payment_details, stamps event_users.has_upgraded.
  5. Payment: a payable cart hands off to the same checkout-sessionStripe → 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.
  6. WL site: redirects to /registration/success/{token}?upgrade=1 — the shared success page renders its upgrade variant; the confirmation email renders the admin-editable upgrade template.
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 email

5. 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.

  1. 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).
  2. WL siteWL API: POST events/{id}/donations/checkoutone 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 as type='individual'); anonymous donors get display_name='anonymous';
    • donations team_split rows — team donations add one row per member, the amount divided evenly;
    • tax_deduction_details — when the donor claims tax, keyed by payment_id so it works for every recipient type (the old per-recipient stash silently dropped team-donation tax details). Then creates the Stripe session (redirect URLs via FrontendUrl) and returns the hosted URL + an encrypted success token.
  3. StripeWL API (webhook) and/or WL siteWL API (verify) — the same idempotent settlement pair as flow 3.
  4. WL APITGP 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 read challenge_team_leaderboard — never a re-sum of donations (the direct sum misses member-attributed rows and reads too low).
  5. WL API: donor confirmation + recipient notification emails (idempotent per payment).
  6. WL siteWL API: GET donations/success/{token} — TxnID, amount, recipient profile link, tax details (read back from tax_deduction_details), and per-recipient share text with self-donation detection.
  7. Drift correction — WL API cron: donations:recalc every 10 minutes sweeps active v3 events (in event_domains status=active, window open): money via TeamDonationRecalcService (individuals pass, then teams), then ranks by calling the TGP stored procedures UpdateDonationRanks(cid, excluded) and UpdateTeamDonationRanks(cid). Two hard rules: pass '', never NULL, for an empty excluded list (NULL makes FIND_IN_SET filter 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 legacy UpdateDonationRank (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.

  1. 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.
  2. Strava → TGP DB: activities arrive as unsynced rows in challenge_activities_log — the raw ingest queue.
  3. 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. A strava:drain-backlog utility exists for historical catch-up.
  4. Legacy — hygiene sweeps (every 2 hours, minute-staggered): DuplicateActivity and DuplicateActivityCronUrlHit catch duplicates; SuspiciousActivity runs CALL GetSuspiciousActivities on the TGP DB to flag anomalous entries.
  5. Admin review: flagged/suspicious/duplicate activities surface in the Admin SPA under Operations → Activity Manager (ActivitiesPage + activity-manager table/drawer components) for manual review and correction.
  6. Legacy — Stage 3 leaderboard sync (per event, minute-staggered by design): the LeaderBoardSync*Stage3 family — outdoor, indoor, team, group, images and seasonal variants — writes the TGA/TGP leaderboard tables.
  7. Legacy → TGP DB: each Stage 3 run then calls the rank stored proceduresUpdateRanks, UpdateHoursRanks, UpdateTeamRanks, UpdateDonationRanks, UpdateSeasonalRanks, UpdateGroupDonationRanks — which recompute rank columns in place.
  8. WL siteWL 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.

  1. Define (Admin). Admin SPAAdmin APIDB: 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) and achievement_cron_condition (subconditions: distance / activity / donation / purchase / sign_up, each with optional date windows and an additional_options JSON blob). Image slots upload like any other image type.
  2. 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 into AchievementUnlockService (V2 for events > 22), which writes winners to challenge_achievement_winners on 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 AchievementUnlockService actually reads them.
    • ⚠️ The bespoke TogoSg61AchievementCron (event 49) is deliberately disabled in the legacy config/schedule.php with an explicit do-not-re-enable comment. Re-enabling it would double-assign winners.
  3. Notify — the < 49 / >= 49 split (07 §2). Both engines read challenge_achievement_winners rows with notified = 0 and flip the flag after sending — the notified flag is the handshake, so exactly one engine must own each event:
    • Events < 49Legacy ChallengeNotification: sends the email and POSTs an in-app push to TGP's api.php; hard-guarded by achievementEmailsEnabled(): (int) $eventId < 49.
    • Events >= 49WL API achievements:notify (every 5 minutes; opt-in per event via the achievement_email_active config key): email only, rendered through EmailTemplateRenderer (achievement_unlock template), links built with FrontendUrl.
  4. 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).
  5. 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 gallery

8. Emails & automation

Template hierarchy and rendering (04-wl-api.md §9)

  1. Defaults ship in the WL API's config/email_templates; per-event overrides live in the DB (configuration key email_templates), edited in the admin's Email Designer.
  2. WL API EmailTemplateRenderer composes block partials, substitutes {{tokens}}, and applies per-block condition gating. Branding comes from EmailBrandingService with 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/branding from_address → global MAIL_FROM_*no-reply@togoparts.com. Live sends abort (logged as failed) if the event can't be resolved; every send is written to mail_logs, which backs the admin Email Logs page.
  3. Admin preview / test-send crosses servers. The Admin SPA calls POST email-templates/preview and test-send on 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

  1. Admin SPAAdmin APIDB: rules live in event_automation_rules (audience/eligibility, trigger cadence, template, enabled flag).
  2. "Send test" goes Admin API → WL API: POST internal/automation/test, authenticated with the shared WL_INTERNAL_TOKEN; the WL API resolves the rule with relaxed time gates and sample fallbacks so previews never read "$0 of $0".
  3. WL API cron: automation:run (hourly) evaluates enabled rules for events whose registration window is open; each rule's own trigger_every_cycle (hourly/6h/12h/daily) gate against last_run_at decides whether it fires this tick. Recipients come from EligibilityResolver; sends go out as AutomationMail; last_run_at/last_user_count feed the admin UI. Non-prod environments filter recipients through NonProdRecipientFilter.
  4. 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

  1. Admin SPAAdmin API: POST events/{id}/domain (DomainController::store). The event_domains row starts status='pending' with a generated TXT challenge — record name _togoactive-verify, value togoactive-verify=<random32>. (A bare IP[:port] — dev/testing — can't carry DNS records and jumps straight to active.)
  2. Admin adds the TXT record at their DNS provider, then Admin SPAAdmin API: verify-txt — the API does a live dns_get_record("_togoactive-verify.<domain>", DNS_TXT); on an exact value match the row becomes status='txt_verified' (ownership proven). DNS propagation can take up to 48 h; the endpoint is retryable.
  3. Admin points the domain's A record at the WL server, then Admin SPAAdmin API: verify-dns — checks the A record resolves to one of our server IPs; on success the row becomes status='active'.
  4. Admin APIWL server: WlDeployService::syncDomains() POSTs {WL_DEPLOY_URL}/sync-domains with the WL_DEPLOY_TOKEN bearer. 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 manual sync endpoint exists for retries. Deleting a domain triggers the same sync to de-provision.
  5. 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-event starts answering for the new host and the site serves the event on the custom domain — no WL build or deploy involved.
  6. 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 Stripe success_url/cancel_url and link-bearing emails always point at the domain the user is actually on. Never build redirects from raw env('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 domain

10. Port users (copy participants between events)

Admin Operations → Port Users; controller PortUserController, engine under the admin backend's app/Services/PortUsers/.

  1. Admin SPA — a 5-step wizard:
    1. pick source event(s) (sourceEvents);
    2. filter the audience (audiencePreview, with a CSV audienceExport for offline checking);
    3. review the schema mapping (schemaDiff — how source registration fields land on the target event's form);
    4. dry-run;
    5. execute.
  2. Dry-run. Admin SPAAdmin API: POST events/{id}/port-users/dry-runPortExecutor::dryRun() simulates the full run and reports would-be creates / skips / conflicts without writing anything.
  3. Execute. Admin SPAAdmin API: POST events/{id}/port-users/execute — refuses (409) if a run is already running for the event, creates a port_runs row, and dispatches the ExecutePortRun job. The job has a 30-minute timeout and tries = 1 — no auto-retry, because a half-retried port would leave confusing partial state. It behaves identically under QUEUE_CONNECTION=sync (blocks the request) and redis (background).
  4. JobDB: PortExecutor copies each selected participant into the target event, writing one port_run_items row per user and stamping every row it creates with the port_run_id — the stamp is what makes the run auditable and revertible. The run finishes completed (with a counts summary) or failed (with the error captured).
  5. Monitor. Admin SPA polls runs/{runId} and lists runs (statuses running | completed | failed | reverted).
  6. Revert. Admin SPAAdmin API: POST runs/{runId}/revertPortReverter deletes exactly the rows stamped with that run's id and marks the run reverted (409 if the run isn't in a revertible state).

Where each flow can break

FlowCommon failureFirst place to look
2 — PropagationAdmin saved but the WL site shows old content for up to a minuteThe 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 pickerFeature works in code but is broken only inside the admin UIStale 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 eventMissing 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 — PaymentsCharges land in the wrong (test) Stripe account; real cards fail in live modeThe 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 — RankingRanks empty / nobody ranked after a recalcNULL 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 — AutomationParticipants receive duplicate automation emailsThe 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 — AchievementsDuplicate awards/notifications on event 49Someone re-enabled TogoSg61AchievementCron in the legacy config/schedule.php, or widened achievementEmailsEnabled() past < 49. Both must stay exactly as-is (07 §10).
7 — AchievementsConditions save fine in the admin but nobody is ever awardedCondition 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 — SettlementPayment succeeded on Stripe but our row stays pending; emails/leaderboard never fireWebhook 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 domainsDomain shows active in admin but the browser gets an SSL error or the default vhostThe 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 — LifecycleA new event's public site is empty or its menu is missingThe 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 — ResolutionIntermittent "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).

Organiser guide and developer documentation for the TogoActive platform.