Skip to content

TogoActive v3 — Developer Guide

This is the onboarding and day-to-day reference for developers working on TogoActive v3. It covers where the code lives, how the dev and production environments are wired together (including the one fact that changes everything: the database is shared across every server and app), how to set up a local environment, how each app is built and deployed, the team's working agreements, and the full list of code conventions and gotchas — every one of which is here because it caused a real bug at least once.

Read this before your first commit. Skim the Debugging playbook again the first time something "impossible" happens — it is almost certainly in there.


Repos & directory map

All four v3 codebases are separate private GitHub repos, all working on the main branch. On the dev box they live under /var/www/togoactive-development/.

Local pathGitHub repoStackDev portRole
/var/www/togoactive-development/admin-backendtga-v3-admin-apiLaravel 10 (PHP ^8.1)8001Admin API consumed by the admin SPA
/var/www/togoactive-development/admin-frontendtga-v3-admin-webReact 18 + Vite 5(static)Admin SPA — served as a static dist/ build; dev proxy /api128.199.72.46:8001
/var/www/togoactive-development/wl-event/frontend-api-wl-developmenttga-v3-wl-apiLaravel 10 (PHP ^8.1)8000White-label (WL) public API — registration, payments, donations, stats
/var/www/togoactive-development/wl-event/frontend-wl-developmenttga-v3-wl-webNext.js 14 (App Router)3000WL public event site (participant-facing)

And one directory that is not a v3 repo but matters constantly:

Local pathWhat it is
/var/www/togoactiveThe legacy production Laravel app (old admin). Still live, still serving users, and still running its cron fleet. Treat as read-mostly reference; changes here affect production immediately.

Environments & deployment topology

There are two servers plus GitHub in the middle. Code moves through GitHub; data does not move at all, because there is only one database.

                          ┌────────────────────────┐
                          │        GitHub          │
                          │  tga-v3-admin-api      │
                          │  tga-v3-admin-web      │
                          │  tga-v3-wl-api         │
                          │  tga-v3-wl-web         │
                          │  (all: main branch)    │
                          └───────┬────────┬───────┘
                     push ▲       │        │  git pull / deploy
                          │       ▼        ▼
   ┌──────────────────────┴───────────┐  ┌─────────────────────────────────┐
   │  DEV / STAGING BOX               │  │  PRODUCTION WL SERVER           │
   │  128.199.72.46                   │  │  wl-api.togoparts.com           │
   │                                  │  │  178.128.113.107                │
   │  admin-backend   :8001 (artisan) │  │                                 │
   │  wl-api          :8000 (artisan) │  │  wl-api (Laravel, Apache/PHP)   │
   │  wl-web          :3000 (next dev)│  │  wl-web: systemd wl-web.service │
   │  admin SPA: static dist/ behind  │  │    `next start` at              │
   │    nginx+php-fpm as              │  │    /var/www/wl_site/v3/         │
   │    v3.togoactive.com             │  │      tga-v3-wl-web              │
   │    (DNS + certbot were pending)  │  │    behind Apache                │
   │                                  │  │                                 │
   │  legacy app /var/www/togoactive  │  │                                 │
   │  (old admin + cron fleet, LIVE)  │  │                                 │
   └────────────────┬─────────────────┘  └────────────────┬────────────────┘
                    │                                     │
                    └────────────────┬────────────────────┘

                    ┌─────────────────────────────────┐
                    │  ONE SHARED DATABASE            │
                    │  DigitalOcean managed MySQL     │
                    │  DB: togoactive                 │
                    │  (also: togoactive-stage)       │
                    │  + mysql_tgp → Togoparts        │
                    │    production DB                │
                    └─────────────────────────────────┘

The one fact to internalize: the DB is shared

Every app on both servers — dev admin API, dev WL API, prod WL API, prod WL web, the legacy app — points at the same DigitalOcean managed MySQL database (togoactive; a togoactive-stage DB also exists on the same cluster). Consequences:

  • Config and data edits are live everywhere, immediately. Change a row in configuration, events_meta, or a feature flag from the dev admin, and production sees it on its next read. There is no "staging data" safety net.
  • Code changes are per-server. New code must be committed, pushed to GitHub, and deployed on each server that needs it. Data behaves globally; code behaves locally.
  • php artisan migrate runs against the live DB. Migrations are real, irreversible production schema changes. Review before running; prefer additive, nullable, backfill-later migrations.

The cross-server trap: admin preview hits the PROD WL API

The admin frontend's email preview / test-send and the donation picker do not call the local dev WL API. They call the production WL API via VITE_WL_API_URL=https://wl-api.togoparts.com. So:

  • If you change wl-api code and the email preview still misbehaves, the most likely cause is a stale deploy on the prod WL server, not your code.
  • These are "preview-only bugs": everything else works locally because everything else hits the local API.

Server-by-server summary

Dev/staging box (128.199.72.46)Prod WL server (wl-api.togoparts.com / 178.128.113.107)
Admin SPAstatic dist/ behind nginx+php-fpm as v3.togoactive.com (DNS + certbot were pending)
Admin APIphp artisan serve on :8001
WL APIphp artisan serve on :8000Deployed here; this is what admin previews call
WL webnext dev on :3000systemd wl-web.service running next start at /var/www/wl_site/v3/tga-v3-wl-web, behind Apache
Legacy app/var/www/togoactive — live, cron fleet running
Databasesharedshared (same DB)

GitHub flow

There are no PRs or feature branches in the day-to-day flow: work happens on main, and after each coherent change set you commit and push the affected repos. The user pulls and deploys per-server themselves (see Working agreements).


Local development setup

A numbered checklist for getting productive on the dev box (or a workstation pointed at it).

  1. Clone all four repos (private; get access from the team): tga-v3-admin-api, tga-v3-admin-web, tga-v3-wl-api, tga-v3-wl-web. On the dev box they map to the paths in the directory table.

  2. Install PHP 8.x and Composer, then in both Laravel apps:

    bash
    cd admin-backend && composer install
    cd ../wl-event/frontend-api-wl-development && composer install
  3. Copy .env files — get DB credentials from the team. Remember: these .envs point at the LIVE / stage shared DB. Treat every query, seeder, and migration as a production operation. Both Laravel apps need the mysql (TGA shared DB) and mysql_tgp (Togoparts production DB) connection pairs filled in.

  4. Start the Laravel APIs (or use the user's run.sh, which wraps all of this):

    bash
    # admin API
    php artisan serve --host 0.0.0.0 --port 8001
    # wl API
    php artisan serve --host 0.0.0.0 --port 8000

    Note artisan serve is single-threaded — fine for dev, but see the "event not connected" gotcha.

  5. Admin frontend:

    bash
    cd admin-frontend
    npm install
    npm run build        # produces dist/ — this is what nginx serves
    # OR, for local HMR against the API proxy:
    npm run dev          # vite dev server; proxies /api → http://128.199.72.46:8001

    On the shared dev box the SPA is served from the static dist/ build — there is no HMR there; you must rebuild after every change.

  6. WL web:

    bash
    cd wl-event/frontend-wl-development
    npm install
    npm run dev          # Next.js dev server on :3000

    Optionally set EVENT_ID in .env.local to pin the site to a single event.

  7. Verify: load the admin SPA (via v3.togoactive.com or the dist build) and log in; load the WL site on :3000 and confirm an event renders. If both work, you're wired up.


Build & deploy reference per app

First: what you must NOT do

Per the team's working agreements (see next section), developers and AI assistants do not:

  • start or restart dev servers, deploy code, or restart systemd services on their own — the user runs everything via run.sh and deploys per-server themselves;
  • browser-verify changes by driving the running site;
  • rm -rf .next on a live/dev-running Next app;
  • run next build in-place on the prod WL server (that is exactly what safe-build.sh exists to prevent).

What you may always do: static checks — php -l, tsc --noEmit / npm run type-check, npm run build / vite build.

admin-frontend (tga-v3-admin-web)

CommandWhat it does
npm run devVite dev server with HMR; proxies /apihttp://128.199.72.46:8001
npm run buildVite production build into dist/
npm run previewPreview the production build locally

Critical: on the dev box the SPA is served from static dist/no HMR. After any edit you MUST npm run build or your change simply will not appear. This is the #1 "my change didn't work" cause.

admin-backend (tga-v3-admin-api)

  • No build step. Edit PHP, it's live on next request (under artisan serve or php-fpm with opcache considerations).
  • php artisan serve --host 0.0.0.0 --port 8001 for dev.
  • php artisan migrateruns on the LIVE shared DB. Double-check before running.
  • Scheduled jobs are driven by php artisan schedule:run (cron).

wl-api (tga-v3-wl-api)

  • Same as admin-backend: no build step, php artisan serve --host 0.0.0.0 --port 8000 in dev, schedule:run-driven crons (notably donations:recalc every 10 minutes and achievements:notify for v3-owned events).
  • Real deployments need nginx+php-fpm, not artisan serve — the single-threaded dev server plus the 60/min rate limit caused intermittent "event not connected" errors on the WL site.

wl-web (tga-v3-wl-web)

CommandWhat it does
npm run devNext.js dev server (:3000)
npm run buildnext build
npm run startnext start (what wl-web.service runs in prod)
npm run lintESLint
npm run type-checktsc --noEmit
npm run deploysafe-build.sh — the only correct way to rebuild the live prod site

safe-build.sh exists because running next build in-place while next start is serving crashes pages intermittently (the running process holds the old manifest; mid-build it reads a mismatched one → entryCSSFiles TypeErrors, unstyled pages, CSS-hash 404s). The safe sequence:

  1. Build into a staging dir .next-build via NEXT_DIST_DIR — the live server keeps serving the old .next the whole time (no 503 window).
  2. Atomic swap: mv .next → .next-old, mv .next-build → .next.
  3. One fast systemctl restart wl-web (Next is ready in ~350ms; Apache's proxy retry absorbs the blip).
  4. Health-checks with Host: sg61.togoparts.com; on failure it rolls back to .next-old.

A failed build never touches the live .next. Never git pull && npm run build by hand on the prod server.


Environment variable reference

admin-frontend (.env, all VITE_* baked in at build time)

VariableDev valuePurpose
VITE_API_URL/api/v1Admin API base path (proxied/rewritten to admin-backend)
VITE_WL_API_URLhttps://wl-api.togoparts.comPROD WL API — used by email preview/test-send and the donation picker. Stale prod deploys = preview-only bugs.
VITE_PUBLIC_SITE_URLhttp://128.199.72.46:3000WL site URL for preview iframes and links (dev only value shown)

Because these are Vite vars, changing them requires a rebuild.

admin-backend (.env)

Variable(s)Purpose
DB_*LIVE shared TGA DB (DigitalOcean managed MySQL). Handle with care.
DB_*_TGPTogoparts production DB (mysql_tgp connection)
DO_*DigitalOcean Spaces (image/file uploads)
SANCTUM_TOKEN_EXPIRATIONAdmin API token lifetime
WL_DEPLOY_URL / WL_DEPLOY_TOKENTrigger WL deploys from admin
WL_REVALIDATE_URL / WL_REVALIDATE_SECRETPing WL cache revalidation after admin mutations
WL_API_URL, WL_INTERNAL_TOKENServer-to-server calls into wl-api
ANTHROPIC_API_KEYAI-assisted features

wl-api (.env)

Variable(s)Purpose
DB_* / DB_*_TGPSame shared-DB + Togoparts pairs as admin-backend
WL_INTERNAL_TOKENShared secret for admin-backend → wl-api calls
(Stripe)Per-event Stripe credentials live in the DB (payment_gateway_config), not in .env. New events fall back to the shared TEST credential.

wl-web (.env.local / .env.example)

VariablePurpose
EVENT_IDPin the site to a single event (optional in dev; set in single-event prod deploys)
NEXT_PUBLIC_API_URLWL API base for the browser
API_BASE_URLWL API base for server-side fetches
REVALIDATE_SECRETAuth for the /api/revalidate cache-bust endpoint
NEXT_DIST_DIRUsed by safe-build.sh to build into .next-build

DB connections (both Laravel apps)

ConnectionPoints atStrict mode
mysqlShared TGA DB (togoactive)admin-backend: strict true; legacy: strict false
mysql_tgpTogoparts production DB — platform users (incl. legacy crypt passwords), Strava/challenge data, denormalized leaderboards, rank stored proceduresstrict false

Strict-mode asymmetry matters: queries and inserts that pass on the legacy app can throw on admin-backend (NOT-NULL/only-full-group-by), e.g. the social_seo NOT-NULL caveat.


Working agreements

How this team ships. These are expectations, not suggestions.

  1. Commit + push per coherent change set, on main. When a change set is complete (traced end-to-end, statically checked), commit and push every affected repo without being asked. Don't batch a day of work into one mega-commit; don't push half-wired features.
  2. No AI attribution in commits or PRs. No Co-Authored-By: Claude, no robot emoji, no "Generated with Claude Code" lines. Commit messages describe the change, period.
  3. The user deploys and runs servers. Never start dev servers, deploy, restart services, or browser-verify on your own initiative — the user runs everything via run.sh and deploys to the prod server themselves. Your job ends at "pushed and statically verified."
  4. Static checks are always fine (and expected): php -l, tsc/npm run type-check, npm run build, vite build.
  5. Rebuild admin-frontend after edits. Static dist/ serving means unbuilt changes are invisible. npm run build is part of finishing an admin-frontend change.
  6. Trace every layer before calling it done. DB column → model → validator → controller → API response → TS type → UI component → save path. A feature with a silent gate anywhere in that chain (a validator dropping a field, a controller whitelist missing a key) is not done, even if the UI looks right.
  7. Treat the DB as production, always. Because it is.

Code conventions per codebase

Every item below caused a real bug. The why is included so you don't relearn it the hard way.

Admin frontend (tga-v3-admin-web)

  • Never use native alert / confirm / prompt. Use the wrappers in src/utils/swal.js (SweetAlert2). Native dialogs block the thread, look broken in the SPA shell, and are inconsistent with every other confirm in the app.
  • Never send FormData through api.put. Axios-style PUT of FormData serializes to "{}" and the backend receives an empty body. Use api.upload (POST) and append fd.append('_method', 'PUT') for Laravel method spoofing.
  • Settings pages follow one pattern. Local form state via useState + a getInitialForm() factory; isDirty by JSON comparison against the initial snapshot; event data via useOutletContext() from EventLayout; UI built from SectionCard / ToggleCard / Toggle / SaveBar with the local-copy convention (edit a copy, commit on save); SetupHelpPanel for shared help. Deviating breaks dirty-tracking and save semantics that users rely on.
  • Image slots: upload ≠ save. Uploading to the DO bucket does not persist the slot — you must PATCH images/{slot}/url on save or the image silently vanishes on reload. Never map canonical slot keys inside IMAGE_FIELD_MAPPING (it exists only for legacy aliases; mapping canonical keys corrupts slot resolution). The cropper is react-image-crop v11 (free-form with a ratio-lock toggle) — not react-easy-crop.
  • Page builder ids: only genId(). Never mint block/column ids from a raw counter or bare Date.now() — two blocks created in the same millisecond get duplicate ids and selection/editing breaks in ways that look like React bugs.
  • Form Builder defaults mirror production exactly. The defaults must match /var/www/togoactive/public/test.json field-for-field. Invented defaults diverge from what production events actually render.

Admin backend (tga-v3-admin-api)

  • One config per concern. New admin copy fields go inside the configuration.event_default_message JSON — do not create sibling configuration.key rows. Sibling rows fragment what is logically one document and multiply the load/save/merge paths.
  • whereColumn does NOT work inside eager-loading constraints. Use DB subquery selects instead. This fails silently — the constraint just doesn't filter — which is worse than an error.
  • Column/table trivia that bites: the users table uses fullname, not name. Featured/popular profiles live in event_featured_profiles, not event_users. event_users.referral_code is the registration referral; payments.coupon_code is the per-payment coupon — they are different concepts.
  • Team rename MUST sync challenge_team_leaderboard.team_name. The leaderboard table denormalizes the name; renaming only the team row leaves stale names on every leaderboard.
  • TGP rank stored procedures: pass '', never NULL, for excluded-user lists. NULL makes the procedure rank nobody (the whole leaderboard empties) instead of excluding no one.
  • challenge_donation_leaderboard individual rows use teamid = 0, never NULL. Downstream joins and the stored procs assume 0; NULL rows fall out of aggregation.

WL API (tga-v3-wl-api)

  • All Stripe redirects and outbound links via FrontendUrl::forEvent() — it is custom-domain aware. Raw env('FRONTEND_URL') sends users on custom-domain events to the wrong host mid-checkout.
  • Per-event Stripe credentials live in the DB (payment_gateway_config); new events fall back to the shared TEST credential. Don't hardcode keys or assume .env Stripe config.
  • Crons: donations:recalc (10-minute sweep + rank stored procs) and achievements:notify run here for v3-owned events. See Cross-system invariants for the ownership split.

WL web (tga-v3-wl-web)

  • "use client" first line in any components/blocks/*.tsx that uses hooks. App Router treats files as server components by default; hooks in a server component are a build/runtime error that surfaces confusingly.
  • No backticks inside dangerouslySetInnerHTML={{__html: `...` }} CSS — even in comments. A backtick inside the template literal terminates it and breaks the build with an error nowhere near the real cause.
  • Block schema changes leave stale saved state. Saved pages keep the old block shape; the first debugging step after changing a block's schema is delete + re-add the block on the affected page, before assuming a code bug.
  • The Custom HTML block strips <script> via DOMPurify. Interactive content (carousels, sliders) cannot ship as Custom HTML — build a native block instead (that's why early_bird_slider exists as a real block).
  • Tokens must resolve from real config. {{share_text}} and friends must come from actual event configuration, never hardcoded fallbacks; gate blocks with condition: ['hasX' => true] so blocks with absent data hide instead of rendering placeholder junk.
  • profile_img starting with uploads/ → TogoActive DO bucket (static.togoactive.com), NOT the Togoparts CDN. Every new avatar/image resolver needs that uploads/ branch or TGA-uploaded avatars 404.

Cross-system invariants

Rules that span multiple apps. Break one of these and the bug shows up in a different codebase than the one you changed.

Ownership split: legacy vs v3 (the event-49 line)

  • Events < 49: the legacy app (/var/www/togoactive) owns activity sync, achievements, and notifications. Its cron fleet is live and doing that job.
  • Events ≥ 49: v3 wl-api owns achievement notification emails (achievements:notify) and donation recalc (donations:recalc every 10 minutes). Achievement awarding (winner rows) still runs on the legacy AchievementMasterCron, which sweeps every open-window event — v3 events included; the challenge_achievement_winners.notified flag is the handshake.
  • Never re-enable the legacy TogoSg61AchievementCron. The generic AchievementMasterCron already awards event 49 — a second awarding cron double-assigns winners.

Denormalization contracts

The shared DB denormalizes aggressively; writers must maintain the copies:

  • challenge_team_leaderboard.team_name must be synced on team rename (admin-backend writes, WL web reads).
  • challenge_donation_leaderboard.raised_fund is recomputed by DonationLeaderboardService::recalculateForPayment, wired to the Stripe webhook + verify paths; the 10-minute donations:recalc sweep is the safety net.
  • Team "raised" totals read challenge_team_leaderboard.raised_fund (keyed by tga_team_id) — never re-sum direct-to-team donations only (that undercounts by omitting member-raised funds).
  • Individual donation-leaderboard rows: teamid = 0, never NULL.

Stored-procedure parameters

TGP rank stored procedures (Togoparts DB, mysql_tgp): excluded-list parameters take '' for "exclude nobody". Passing NULL ranks nobody. This applies to every caller — legacy crons, donations:recalc, ad-hoc scripts.

Cache & preview pipeline

  • WL caching: the WL site holds a 60s server-side TTL cache per event. Admin mutations ping revalidation via WlCache (→ api/revalidate on wl-web; a /clear-cache endpoint also exists). A hard reload bypasses the cache. So: admin change not showing on WL within a minute = check the revalidate ping, not your save.
  • Preview iframes: PageBuilder embeds WL /builder-preview?preview=1 and talks a pb-* postMessage protocol; FormBuilder embeds /registration-preview and talks fb-*. Preview URL resolution order: event custom domain → VITE_PUBLIC_SITE_URL → host :3000.
  • Cross-server preview calls: email preview/test-send and the donation picker call the prod WL API (VITE_WL_API_URL) — see topology.

Debugging playbook

Symptoms → most likely cause → fix. Check these before deep-diving.

"My admin-frontend change isn't visible"

The SPA is served from static dist/ with no HMR. Run npm run build in admin-frontend/. If you're on npm run dev locally and it's still stale, check you're loading the dev server URL, not the static deployment.

"Email preview / test-send / donation picker is broken, but only in admin"

These call the production WL API (https://wl-api.togoparts.com), not your local wl-api. If you changed wl-api code, the prod server has a stale deploy — the fix is pushing and deploying wl-api there, not local debugging. Everything else works because everything else hits the local API.

Intermittent "event not connected" on the WL site

Historic cause: single-threaded php artisan serve + the 60/min rate limit — concurrent SSR fetches starve and fail. Mitigated with a frontend TTL cache; the real cure for any serious deployment is nginx+php-fpm instead of artisan serve.

Chunk errors / broken imports after npm install

Running npm install while a Next or Vite dev server is running invalidates its module graph → chunk load errors. Restart the dev server after any install. Do not start deleting build dirs.

Whole-app 404s (every route, including ones that worked)

Almost always dev-server state, not your code. Restart the dev server (which the user does via run.sh). Never rm -rf .next on a live/running dev server — it makes things worse.

Prod WL site: "Something went wrong" / unstyled pages / CSS 404s during deploy

Someone ran next build in-place while wl-web.service was serving. Use npm run deploy (safe-build.sh) — staging-dir build, atomic swap, restart, health-check, rollback.

Page builder: clicking one block selects/edits another

Duplicate block/column ids — something minted ids with a raw counter or bare Date.now(). Mint ids only via genId(), and repair the affected saved page (re-add the duplicated blocks).

A block renders wrong after a schema change

Stale saved state: the page still holds the block's old shape. First fix: delete and re-add the block on the affected page. Only if a freshly added block is still wrong is it a code bug.

Admin save "succeeds" but data doesn't stick

Trace the layers: is the field in the validator? The controller whitelist/fillable? The API response? The TS type? The UI's getInitialForm()? Common specific cases: FormData via api.put (body becomes "{}" — use api.upload + _method=PUT); image uploaded to bucket but slot never PATCHed (images/{slot}/url).

WL site doesn't reflect an admin change

60s per-event cache. Wait a minute or hard-reload; if it never appears, check that the admin mutation pings WlCache revalidation and that WL_REVALIDATE_URL/REVALIDATE_SECRET line up on both sides.

Leaderboard suddenly empty after a rank/recalc change

Someone passed NULL instead of '' to a TGP rank stored procedure's excluded list — NULL ranks nobody. Also check individual donation rows kept teamid = 0 (not NULL).

Duplicate achievements/notifications on an event

Duplicate emails: both notification engines own the event — check the event-49 split (legacy achievementEmailsEnabled() vs v3 achievements:notify). Duplicate awards: a per-event awarding cron (e.g. the legacy TogoSg61AchievementCron) was re-enabled alongside AchievementMasterCron.


Doc 09 of the TogoActive v3 documentation set. Verify commands and env values against the repos before relying on them in new environments; the shared-DB and ownership-split invariants change rarely, but deploy details evolve.

Organiser guide and developer documentation for the TogoActive platform.