Appearance
Legacy App & Togoparts (TGP) Integration
The original TogoActive production Laravel app at /var/www/togoactive (the "old admin") is still live and still runs the cron machinery that powers activity ingestion, leaderboards, donation ranking, and achievements for every event launched before v3. The new v3 stack (admin backend, admin frontend, WL API, WL web) does not replace it — the two systems run side by side against the same production database, with a deliberate ownership split (legacy owns events < 49 end-to-end; for events >= 49 v3 owns achievement notifications and donation recalc, while achievement awarding still runs on legacy crons). Both systems also depend on a second, external platform: Togoparts (TGP), whose production database (mysql_tgp) holds the raw Strava/activity data and the stored procedures that compute ranks. This document explains how the coexistence works, how TGP is wired in, which cron owns what, and the rules you must not break.
Related docs: ./01-architecture-overview.md · ./04-wl-api.md · ./06-database-schema.md
1. Why two systems share one database
- The legacy app and the v3 apps (admin backend + WL API) all point at the same TGA production database (the
mysqlconnection in the legacy app). There is no data migration or sync layer between "old" and "new" — they are two codebases over one schema. - Consequence for operations: anything stored in the database — configuration rows, event settings, feature flags, content — edited from the new admin takes effect for the legacy app immediately (and vice versa). Only code changes require a deploy, and deploys are per-server (the legacy app, the v3 admin backend, and the v3 WL API live on different servers/paths).
- The legacy app additionally opens a cross-database connection to Togoparts production (
mysql_tgp) for Strava/activity/challenge data and rank stored procedures (see §3). The v3 WL API uses the same TGP integration for its own donation-ranking cron. - Because the legacy scheduler hammers the shared DB with per-event crons, it protects itself with a DB-CPU self-throttle (
isDbHigh, see §8) and deliberate minute-staggering of cron start times.
2. Ownership split: legacy vs v3
The < 49 / >= 49 rule
The dividing line is event ID 49 (SG61, the first v3-native event) — but note the split applies to the notification/email step, not to awarding. The legacy generic AchievementMasterCron (minutes 2,12..52) still awards achievements — inserting challenge_achievement_winners rows — for every event with an open window (registration started, results date not more than an hour past), v3 events included:
| Event IDs | Achievement awarding (winner rows) | Achievement emails / notifications sent by |
|---|---|---|
< 49 | Legacy AchievementMasterCron + bespoke per-event crons | Legacy ChallengeNotification cron |
>= 49 | Legacy AchievementMasterCron (still) | v3 achievements:notify cron |
The handshake between the two systems is the shared achievement-winner table challenge_achievement_winners and its notified flag. The legacy cron inserts winner rows; whichever notification engine owns the event sends and flips the flag. Because both systems read and write the same table, exactly one notification engine must own each event or participants get duplicate (or zero) notifications.
The guard lives in the legacy app, app/Console/Commands/ChallengeNotification.php (lines ~165–184):
php
protected function achievementEmailsEnabled($eventId): bool
{
// Events < 49 → this cron owns them. Events >= 49 → v3 wl-api achievements:notify cron.
return (int) $eventId < 49;
}The SG61 story
Event 49 (SG61, TGP cid 119) was the crossover event. The legacy app has a fully-built TogoSg61AchievementCron command — it is still registered in Kernel.php — but its scheduler entry in config/schedule.php (lines ~338–349) is commented out and disabled, with an explicit note:
DISABLED: event 49 (SG61) achievements are handled by the v3 system. Do NOT re-enable without confirming v3 is not also assigning them.
If it were enabled it would run at minutes 5,20,35,50 with an end date of 2026-09-15 18:00. Leave it disabled: event 49's winners are already awarded by the generic AchievementMasterCron (with the emails sent by v3's achievements:notify); re-enabling the bespoke cron would double-assign winners in the shared challenge_achievement_winners table.
3. The TGP (Togoparts) platform integration
TGP is a separate production platform with its own database. The legacy app (and v3 WL API) integrate with it through three channels:
3.1 Direct DB access (mysql_tgp connection)
Defined in config/database.php (DATABASE_URL_TGP / DB_HOST_TGP / etc., utf8mb4_unicode_520_ci, strict => false). This connection holds:
- Strava activity logs (
challenge_activities_log— the raw ingest queue) - Challenge users / challenge membership data
- The rank stored procedures below
3.2 Stored procedures (called on mysql_tgp, results land in shared TGA tables)
| Stored procedure | Purpose |
|---|---|
UpdateRanks | Recompute distance/standard leaderboard ranks for a challenge |
UpdateHoursRanks | Recompute hours-based (time) leaderboard ranks |
UpdateDonationRanks | Recompute individual donation-fundraising ranks |
UpdateTeamDonationRanks | Recompute team donation-fundraising ranks |
UpdateTeamRanks | Recompute team activity leaderboard ranks |
UpdateGroupDonationRanks | Recompute group-level donation ranks |
UpdateSeasonalRanks | Recompute per-season ranks (seasonal events, e.g. TogoRide) |
GetSuspiciousActivities | Flag anomalous/suspicious activities for review |
Critical parameter rule: the donation-rank procedures take an excluded user ids parameter. Passing NULL causes the procedure to rank nobody — you must pass an empty string '' when there are no exclusions. (The v3 donations:recalc cron was bitten by this; the rule applies to any caller.)
3.3 HTTP bridges
ChallengeNotificationPOSTs to{domain}/api.phpon TGP to deliver achievement notifications.DonationCaculationCronUrlHitHTTP-hits{domain}/api/calculate/teamand{domain}/api/calculate/individualsper active event to trigger donation recalculation. These calculate URLs 404 on v3 events — v3 replaced this pattern (see §5).
3.4 Event → challenge id mapping
A TGA event maps to a TGP challenge via a static map covering events 8–37, with a fallback to events_meta.TGP_CHALLENGE_ID for newer events. Admins set the CID field in the new admin, which writes that meta row. (Example: event 46 → cid 116, event 49/SG61 → cid 119.) UpdateDonationRank only processes events >= 33 that have a TGP_CHALLENGE_ID.
4. Activity pipeline (Strava → leaderboards → ranks)
The end-to-end flow, all orchestrated by legacy crons:
Strava API
│ (Stage 2: ingest)
▼
challenge_activities_log (TGP DB, unsynced rows) ──► activities (TGA DB)
│ (Stage 3: per-event leaderboard sync)
▼
TGA leaderboard tables ──► CALL rank stored procedures on mysql_tgp- Stage 2 ingest —
LeaderboardStage2Sync(every 3 minutes, global): reads unsyncedchallenge_activities_logrows on TGP, fetches full activity data from the Strava API, and writes into the activities tables. Supports--mode=live|backlog. A one-offstrava:drain-backlogcommand drains historical backlog and pre-marks users with revoked Strava tokens. - Dedup & fraud sweeps (global, every 2 hours):
DuplicateActivity(minute 0),DuplicateActivityCronUrlHit(minute 20), andSuspiciousActivity(minute 40,CALL GetSuspiciousActivities). - Stage 3 leaderboard sync (per event, staggered): the
LeaderBoardSync*Stage3family writes TGA leaderboard tables and then calls the TGP stored procedures to recompute ranks:LeaderBoardSyncStage3OutdoorActivities— main per-event sync;--id --type=outdoor|indoor --mode=activity; callsUpdateDonationRanks/UpdateHoursRanks/UpdateRanks.LeaderBoardSyncOutdoorActivitiesStage3— event-43 sibling variant.SeasonalLeaderBoardSyncOutdoorActivitiesStage3—--season; callsUpdateSeasonalRanks.LeaderBoardSyncTeamActivitiesStage3— team sync; callsUpdateDonationRanks+UpdateTeamDonationRanks+UpdateTeamRanks.LeaderBoardSyncTeamActivitiesForCHStage3— company variant (not scheduled).LeaderBoardSyncGroupActivitiesStage3— callsUpdateGroupDonationRanks.LeaderBoardSyncActivitiesImagesStage3— syncs activity images.
- Utility commands:
clone:event-activitiesandresync:eventfor manual repair/backfill.
5. Donation ranking pipeline: legacy vs v3
Two parallel mechanisms exist; which one applies depends on the event's owner. (One caveat: the legacy UpdateDonationRank cron is not id-gated — it processes every event ≥ 33 with a TGP_CHALLENGE_ID, v3 events included. That overlap is harmless because rank recomputes are idempotent; it is the calculate-URL mechanism that only works for legacy events.)
Legacy (events < 49)
UpdateDonationRank(every 3 minutes): for events>= 33that have aTGP_CHALLENGE_ID, callsCALL UpdateDonationRanks+CALL UpdateTeamDonationRanksonmysql_tgp, excluding the host's TGP user ids from ranking.DonationCaculationCronUrlHit(minutes 10,25,40,55): HTTP-hits{domain}/api/calculate/teamand/api/calculate/individualsper active event (events 25, 23, 33 excluded) to recompute raised amounts.achievement:remove-invalid-donation-winners— one-off cleanup command, dry-run by default.
v3 (events >= 49)
- The legacy
/api/calculate/*URLs 404 for v3 events — do not point the URL-hitter at them. - The v3 WL API runs its own
donations:recalccron every 10 minutes: a sweep that recomputes donation totals and calls the same TGP rank stored procedures (remember: pass'', neverNULL, for the excluded-ids parameter). - v3 also exposes token-checked internal recalc endpoints that replace the URL-hit pattern for event-driven recalculation:
internal/events/{id}/recalc-targetsinternal/events/{id}/recalc-individualsinternal/events/{id}/teams/{teamId}/recalc-target
6. Achievement pipeline
Per-event achievement crons (legacy)
Historically each event got a bespoke achievement cron in app/Console/Commands; newer events use the generic master cron.
| Command | Event |
|---|---|
AchievementMasterCron (--id) | Generic, any event; uses AchievementUnlockService |
Decypher2026 | 36 |
RacetoRaise | 32 |
SecMoveEarth | SEC Move the Earth |
Cyclehome2025 | Cyclehome 2025 |
Gomad2024 / Gomad2025 | GoMad |
CwaAchievementCron | CWA |
LBCyclethoneAchievementCron | LB Cyclethone |
TogoRide2026AchievementCron | 43 |
TogoSg60 | SG60 |
TogoSg61 | 49 — registered but NOT scheduled; awarding runs via AchievementMasterCron, notification via v3 (§2) |
togoride2024 chunk/season commands | TogoRide 2024 |
TogoRide2025/ subdir: YearLongAchievementCron, Season1AchievementCron–Season4AchievementCron, seasonal-badge (daily 18:01) | TogoRide 2025 |
PortUserCron / port:user | User porting utility |
A trash/ subdirectory holds retired crons.
Notification flow
ChallengeNotification(--id --userid): reads unnotified rows fromchallenge_achievement_winners, POSTs the notification to{domain}/api.phpon TGP, marksnotified. Contains the< 49/>= 49ownership guard (§2). Scheduled per event (staggered) plus a global no-id run at minutes 5,20,35,50.notification-management-command— every minute, general notification dispatch.MailForCompleteRewardPayment— abandoned-checkout reminder emails.app:achievment-reward-unlock— reward unlock processing (note the "achievment" spelling in the signature).hiturl— clears/warms the achievement page cache and health-checks it (minutes 7,17..57).
7. Full cron reference
Legacy scheduler behavior (app/Console/Kernel.php): schedule() runs only when APP_ENV == production. It is data-driven — it loops over config('schedule.commands'), honoring per-entry startDateTime/endDateTime gates, cronExpression or frequency, before/after logging hooks (writing to the cronJobs log channel), and skipWhen (isDbHigh, §8). A few jobs are hard-coded in the Kernel outside the config array (noted below).
Global (not event-specific)
| Command | Cadence | Notes |
|---|---|---|
LeaderboardStage2Sync | */3 * * * * | Core Strava ingest (Stage 2) |
DuplicateActivity | 0 */2 * * * | Dedup sweep |
DuplicateActivityCronUrlHit | 20 */2 * * * | Dedup via URL hit |
SuspiciousActivity | 40 */2 * * * | CALL GetSuspiciousActivities |
UpdateDonationRank | */3 * * * * | All events ≥ 33 with TGP_CHALLENGE_ID |
DonationCaculationCronUrlHit | 10,25,40,55 * * * * | Legacy calculate-URL hitter (404s on v3 events) |
ChallengeNotification (no id) | 5,20,35,50 * * * * | Global notification pass |
AchievementMasterCron (no id) | 2,12,22,32,42,52 * * * * | Generic achievement awarding — sweeps all events with an open window (registration started, results date ≤ 1 h past), incl. events ≥ 49 |
notification-management-command | * * * * * | |
TriggerManagementCron --activity-sync | * * * * * | Full run: 0 */6 * * * |
app:random-assign-profile-picture-cron | * * * * * | |
app:db-cpu-report-command | * * * * * | Feeds isDbHigh; no skipWhen (must always run) |
hiturl | 7,17..57 * * * * | Achievement page cache/health |
generate:csv / generate-csv | every minute (hard-coded) | Loops over CsvJob rows |
cleanup:old-configurations | 0 1,13 * * * | |
logs:clean | 0 2 * * * | |
| TR25 seasonal-badge | daily 18:01 (hard-coded) | |
TR25 YearLongAchievementCron | hourly at :30 (hard-coded, 2025 window) | |
| TR26 (43) seasonal block | hard-coded: season achievement cron hourly at :15; SeasonalLeaderBoardSync outdoor+indoor hourly, dropping to every 6 h during a 10-day post-season buffer |
Per-event (minute-staggered by design — see §8)
| Event | Command | Minutes | End date |
|---|---|---|---|
| 37 AMP UP! | Outdoor Stage 3 | */3 | 2026-02-20 |
| 37 | Indoor Stage 3 | 1,4..58 | 2026-02-20 |
| 37 | ChallengeNotification | 2,12..52 | 2026-02-20 |
| 37 | Team Stage 3 | 4,14..54 | 2026-02-20 |
| 37 | Images Stage 3 | 6,16..56 | 2026-02-20 |
| 36 Decypher | Outdoor Stage 3 | */3 | 2026-04-03 18:00 |
| 36 | Notify | 3,13..53 | 2026-04-03 18:00 |
| 36 | Team Stage 3 | 5,15..55 | 2026-04-03 18:00 |
| 36 | Decypher2026 achievements | 7,17..57 | 2026-04-03 18:00 |
| 36 (+45) | Images Stage 3 | 9,19..59 | — |
| 32 RaceToRaise | Outdoor Stage 3 | */3 | 2026-02-28 |
| 32 | Indoor Stage 3 | 2,5..59 | 2026-02-28 |
| 32 | RacetoRaise achievements | 1,11..51 | 2026-02-28 |
| 32 | Notify | 8,18..58 | 2026-02-28 |
| 32 | Team Stage 3 | */10 | 2026-02-28 |
| SEC Move Earth | SecMoveEarth achievements | 3,13..53 | 2026-05-25 |
| 43 TogoRide 2026 | Outdoor Stage 3 | */10 | 2027-01-23 18:00 |
| 43 | Indoor Stage 3 | 5,15..55 | 2027-01-23 18:00 |
| 43 | Notify | 4,14..54 | 2027-01-23 18:00 |
| 43 | Team Stage 3 | 6,21,36,51 | 2027-01-23 18:00 |
| 43 | TogoRide2026AchievementCron | 8,23,38,53 | 2027-01-23 18:00 |
| 43 | Images Stage 3 | 12,27,42,57 | 2027-01-23 18:00 |
| 49 SG61 | TogoSg61AchievementCron | 5,20,35,50 | (would be 2026-09-15 18:00) |
| 33 | Outdoor Stage 3 | */3 | 2026-12-31 |
| 33 | Indoor Stage 3 | 2,7..57 | 2026-12-31 |
| 33 | Team Stage 3 | 13,23..53 | 2026-12-31 |
| 33 | Images Stage 3 | 12,27,42,57 | 2026-12-31 |
| 45 | Outdoor Stage 3 | */5 | 2026-05-18 |
| 45 | Indoor Stage 3 | 3,8..58 | 2026-05-18 |
| 45 | Team Stage 3 | 11,21..51 | 2026-05-18 |
| 45 | Notify | 6,16..56 | 2026-05-18 |
| 46 | Outdoor Stage 3 | 2,7..57 | 2026-12-31 |
| 46 | Team Stage 3 | 17,27..57 | 2026-12-31 |
| 46 | Notify | 1,11..51 | 2026-12-31 |
| 46 | Images Stage 3 | 14,24..54 | 2026-12-31 |
| 46 | Group Stage 3 | 0,10..50 | 2026-12-31 |
| 47 #STRIKEFORCARE2026 | Outdoor Stage 3 | 4,9..59 | — |
| 47 | Team Stage 3 | 19,29..59 | — |
| 47 | Notify | 3,13..53 | — |
| 47 | Images Stage 3 | 16,26..56 | — |
| 48 | Outdoor Stage 3 | */5 | — |
| 48 | Indoor Stage 3 | 3,8..58 | — |
| 48 | Team Stage 3 | 11,21..51 | — |
| 48 | Notify | 7,17..57 | — |
| 48 | Images Stage 3 | 18,28..58 | — |
(Notation a,b..z = every 10 minutes starting at minute a; e.g. 2,12..52 = minutes 2,12,22,32,42,52.)
Unscheduled / utility commands
ManageTgpEventsListCron, LeaderBoardSyncTeamActivitiesForCHStage3, strava:drain-backlog, achievement:remove-invalid-donation-winners, clone:event-activities, resync:event, PortUserCron/port:user, plus make-service / sushi-model scaffolders. Retired crons live in app/Console/Commands/trash/.
8. DB-load protection
Two mechanisms keep the shared DB alive under this cron volume:
isDbHighself-throttle. Nearly every scheduled entry carries'skipWhen' => 'isDbHigh'(implemented inapp/Helpers/general.php:846). The signal is produced byapp:db-cpu-report-command, which runs every minute (deliberately without askipWhen) and reads thresholds/connection details fromconfig/db_cpu_conf.php. When DB CPU is high, scheduled runs are skipped rather than queued.- Minute staggering. Every per-event cron in
config/schedule.phpstarts at a different minute offset (documented in the config comments) so that outdoor/indoor/team/images/notify jobs for different events never fire simultaneously. When adding a scheduled command, pick an unused minute offset — do not add another* * * * *or a*/3aligned with existing ones.
9. Legacy config inventory
From /var/www/togoactive/config:
Database connections (database.php)
| Connection | Target | Notes |
|---|---|---|
mysql (default) | Shared TGA production app DB | DB_HOST etc.; utf8mb4; strict => false |
mysql_tgp | TGP production DB | DATABASE_URL_TGP/DB_HOST_TGP...; utf8mb4_unicode_520_ci; strict => false. Holds Strava logs, challenge users, rank stored procedures |
mysql_tga_stage | TGA staging | latin1 |
mysql_wp | WordPress DB | |
| Redis | predis, default + cache |
Other config
db_cpu_conf.php— DB-CPU monitor config. ⚠️ Security cleanup item: contains a hardcoded fallback to the TGP production DB host (tgp-production-db-do-user-3163646-0.b.db.ondigitalocean.com, port 9273) with a plaintext credential fallback (userprom2qjr, password committed in the file). This should be moved to environment variables and the credential rotated.filesystems.php—local/public/s3/do; thedodisk is DigitalOcean Spaces (DO_BUCKET,DO_CDN_ENDPOINT, …) serving images/assets.mail.php— defaultsmtpmailer pointed at a Mailgun host, plus afailovermailer.services.phpcarries Mailgun/Postmark/SES credentials blocks.onesignal.php— OneSignal push notification config.queue.php— default queue issync: jobs run inline in the request/cron process; there are no queue workers to deploy or restart for the legacy app.- No Stripe keys in config. The TGP integration surface is exactly: the
mysql_tgpDB connection, the stored procedures, and the HTTP POSTs to{domain}/api.phpand{domain}/api/calculate/*. schedule.php— the data-driven cron table described in §7.- Miscellaneous app config:
event-setting.php,event-meta.php,achievement-setup-form.php,leaderboard.php,leaderboard-default-style.php,miscellaneous.php,menu-setting.php,permission.php,avatars.php,image.php,larabug.php,pulse.php,datatables.php.
10. What developers must NOT do
- Do not re-enable
TogoSg61AchievementCroninconfig/schedule.php. Event 49's winners are already awarded by the genericAchievementMasterCron(with emails from v3'sachievements:notify); re-enabling the bespoke cron would double-assign winners in the sharedchallenge_achievement_winnerstable. The config comment says the same. - Do not widen
achievementEmailsEnabled()inChallengeNotification.phppast< 49without first disabling the corresponding v3achievements:notifyownership — the split is the entire coexistence contract. - Do not point
DonationCaculationCronUrlHit(or anything else) at/api/calculate/*for v3 events — those URLs 404 on v3. Use the v3donations:recalccron and the token-checkedinternal/events/{id}/recalc-*endpoints instead. - Never pass
NULLas the excluded-user-ids parameter to the TGP donation-rank stored procedures (UpdateDonationRanks,UpdateTeamDonationRanks, …).NULLmakes the procedure rank nobody; pass''when there are no exclusions. - Do not add a scheduled command without a
skipWhen => isDbHigh(unless, likeapp:db-cpu-report-command, it is the monitor) and without picking a fresh minute stagger. The shared DB is the bottleneck for both legacy and v3. - Do not assume a config/DB change needs a legacy deploy — the DB is shared, so data changes are live everywhere instantly. Conversely, do not assume a code change is live everywhere — code is deployed per server.
- Do not print or copy the
db_cpu_conf.phpfallback credentials anywhere; treat them as compromised-by-commit and schedule rotation. - Remember the legacy scheduler only runs under
APP_ENV=production— crons silently doing nothing on a staging copy is expected behavior, not a bug.