Appearance
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.
Related docs
- 01 — Architecture Overview — system-level map of all four apps, the legacy app, and Togoparts.
- 02 — Admin Frontend — React admin SPA: pages, components, patterns.
- 03 — Admin Backend API — Laravel admin API: controllers, services, auth.
- 04 — WL API — Laravel white-label API: registration, payments, donations.
- 05 — WL Frontend — Next.js public site: page builder blocks, rendering pipeline.
- 06 — Database Schema — shared schema, key tables, denormalized structures.
- 07 — Legacy App & TGP Integration — the old admin, cron fleet, and Togoparts DB.
- 08 — Event Setup Guide — operator handbook: menus, setup flow, troubleshooting.
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 path | GitHub repo | Stack | Dev port | Role |
|---|---|---|---|---|
/var/www/togoactive-development/admin-backend | tga-v3-admin-api | Laravel 10 (PHP ^8.1) | 8001 | Admin API consumed by the admin SPA |
/var/www/togoactive-development/admin-frontend | tga-v3-admin-web | React 18 + Vite 5 | (static) | Admin SPA — served as a static dist/ build; dev proxy /api → 128.199.72.46:8001 |
/var/www/togoactive-development/wl-event/frontend-api-wl-development | tga-v3-wl-api | Laravel 10 (PHP ^8.1) | 8000 | White-label (WL) public API — registration, payments, donations, stats |
/var/www/togoactive-development/wl-event/frontend-wl-development | tga-v3-wl-web | Next.js 14 (App Router) | 3000 | WL public event site (participant-facing) |
And one directory that is not a v3 repo but matters constantly:
| Local path | What it is |
|---|---|
/var/www/togoactive | The 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 migrateruns 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 SPA | static dist/ behind nginx+php-fpm as v3.togoactive.com (DNS + certbot were pending) | — |
| Admin API | php artisan serve on :8001 | — |
| WL API | php artisan serve on :8000 | Deployed here; this is what admin previews call |
| WL web | next dev on :3000 | systemd 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 | — |
| Database | shared | shared (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).
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.Install PHP 8.x and Composer, then in both Laravel apps:
bashcd admin-backend && composer install cd ../wl-event/frontend-api-wl-development && composer installCopy
.envfiles — 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 themysql(TGA shared DB) andmysql_tgp(Togoparts production DB) connection pairs filled in.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 8000Note
artisan serveis single-threaded — fine for dev, but see the "event not connected" gotcha.Admin frontend:
bashcd 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:8001On the shared dev box the SPA is served from the static
dist/build — there is no HMR there; you must rebuild after every change.WL web:
bashcd wl-event/frontend-wl-development npm install npm run dev # Next.js dev server on :3000Optionally set
EVENT_IDin.env.localto pin the site to a single event.Verify: load the admin SPA (via
v3.togoactive.comor the dist build) and log in; load the WL site on:3000and 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.shand deploys per-server themselves; - browser-verify changes by driving the running site;
rm -rf .nexton a live/dev-running Next app;- run
next buildin-place on the prod WL server (that is exactly whatsafe-build.shexists 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)
| Command | What it does |
|---|---|
npm run dev | Vite dev server with HMR; proxies /api → http://128.199.72.46:8001 |
npm run build | Vite production build into dist/ |
npm run preview | Preview 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 serveor php-fpm with opcache considerations). php artisan serve --host 0.0.0.0 --port 8001for dev.php artisan migrate— runs 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 8000in dev,schedule:run-driven crons (notablydonations:recalcevery 10 minutes andachievements:notifyfor 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)
| Command | What it does |
|---|---|
npm run dev | Next.js dev server (:3000) |
npm run build | next build |
npm run start | next start (what wl-web.service runs in prod) |
npm run lint | ESLint |
npm run type-check | tsc --noEmit |
npm run deploy | safe-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:
- Build into a staging dir
.next-buildviaNEXT_DIST_DIR— the live server keeps serving the old.nextthe whole time (no 503 window). - Atomic swap:
mv .next → .next-old,mv .next-build → .next. - One fast
systemctl restart wl-web(Next is ready in ~350ms; Apache's proxy retry absorbs the blip). - 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)
| Variable | Dev value | Purpose |
|---|---|---|
VITE_API_URL | /api/v1 | Admin API base path (proxied/rewritten to admin-backend) |
VITE_WL_API_URL | https://wl-api.togoparts.com | PROD WL API — used by email preview/test-send and the donation picker. Stale prod deploys = preview-only bugs. |
VITE_PUBLIC_SITE_URL | http://128.199.72.46:3000 | WL 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_*_TGP | Togoparts production DB (mysql_tgp connection) |
DO_* | DigitalOcean Spaces (image/file uploads) |
SANCTUM_TOKEN_EXPIRATION | Admin API token lifetime |
WL_DEPLOY_URL / WL_DEPLOY_TOKEN | Trigger WL deploys from admin |
WL_REVALIDATE_URL / WL_REVALIDATE_SECRET | Ping WL cache revalidation after admin mutations |
WL_API_URL, WL_INTERNAL_TOKEN | Server-to-server calls into wl-api |
ANTHROPIC_API_KEY | AI-assisted features |
wl-api (.env)
| Variable(s) | Purpose |
|---|---|
DB_* / DB_*_TGP | Same shared-DB + Togoparts pairs as admin-backend |
WL_INTERNAL_TOKEN | Shared 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)
| Variable | Purpose |
|---|---|
EVENT_ID | Pin the site to a single event (optional in dev; set in single-event prod deploys) |
NEXT_PUBLIC_API_URL | WL API base for the browser |
API_BASE_URL | WL API base for server-side fetches |
REVALIDATE_SECRET | Auth for the /api/revalidate cache-bust endpoint |
NEXT_DIST_DIR | Used by safe-build.sh to build into .next-build |
DB connections (both Laravel apps)
| Connection | Points at | Strict mode |
|---|---|---|
mysql | Shared TGA DB (togoactive) | admin-backend: strict true; legacy: strict false |
mysql_tgp | Togoparts production DB — platform users (incl. legacy crypt passwords), Strava/challenge data, denormalized leaderboards, rank stored procedures | strict 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.
- 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. - 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. - 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.shand deploys to the prod server themselves. Your job ends at "pushed and statically verified." - Static checks are always fine (and expected):
php -l,tsc/npm run type-check,npm run build,vite build. - Rebuild admin-frontend after edits. Static
dist/serving means unbuilt changes are invisible.npm run buildis part of finishing an admin-frontend change. - 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.
- 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 insrc/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
FormDatathroughapi.put. Axios-style PUT of FormData serializes to"{}"and the backend receives an empty body. Useapi.upload(POST) and appendfd.append('_method', 'PUT')for Laravel method spoofing. - Settings pages follow one pattern. Local form state via
useState+ agetInitialForm()factory;isDirtyby JSON comparison against the initial snapshot; event data viauseOutletContext()fromEventLayout; UI built fromSectionCard/ToggleCard/Toggle/SaveBarwith the local-copy convention (edit a copy, commit on save);SetupHelpPanelfor 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}/urlon save or the image silently vanishes on reload. Never map canonical slot keys insideIMAGE_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 bareDate.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.jsonfield-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_messageJSON — do not create siblingconfiguration.keyrows. Sibling rows fragment what is logically one document and multiply the load/save/merge paths. whereColumndoes 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
userstable usesfullname, notname. Featured/popular profiles live inevent_featured_profiles, notevent_users.event_users.referral_codeis the registration referral;payments.coupon_codeis 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
'', neverNULL, for excluded-user lists.NULLmakes the procedure rank nobody (the whole leaderboard empties) instead of excluding no one. challenge_donation_leaderboardindividual rows useteamid = 0, neverNULL. 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. Rawenv('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.envStripe config. - Crons:
donations:recalc(10-minute sweep + rank stored procs) andachievements:notifyrun here for v3-owned events. See Cross-system invariants for the ownership split.
WL web (tga-v3-wl-web)
"use client"first line in anycomponents/blocks/*.tsxthat 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 whyearly_bird_sliderexists 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 withcondition: ['hasX' => true]so blocks with absent data hide instead of rendering placeholder junk. profile_imgstarting withuploads/→ TogoActive DO bucket (static.togoactive.com), NOT the Togoparts CDN. Every new avatar/image resolver needs thatuploads/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:recalcevery 10 minutes). Achievement awarding (winner rows) still runs on the legacyAchievementMasterCron, which sweeps every open-window event — v3 events included; thechallenge_achievement_winners.notifiedflag is the handshake. - Never re-enable the legacy
TogoSg61AchievementCron. The genericAchievementMasterCronalready 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_namemust be synced on team rename (admin-backend writes, WL web reads).challenge_donation_leaderboard.raised_fundis recomputed byDonationLeaderboardService::recalculateForPayment, wired to the Stripe webhook + verify paths; the 10-minutedonations:recalcsweep is the safety net.- Team "raised" totals read
challenge_team_leaderboard.raised_fund(keyed bytga_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/revalidateon wl-web; a/clear-cacheendpoint 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=1and talks apb-*postMessage protocol; FormBuilder embeds/registration-previewand talksfb-*. 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.