Appearance
03 — Admin Backend API (v3)
The v3 Admin Backend is a Laravel 10 JSON API living at /var/www/togoactive-development/admin-backend. It serves the new React admin SPA (see 02-admin-frontend.md) over a single versioned prefix, /api/v1, and is the write-path for almost everything an event organiser configures: event setup, appearance, pages, registration, rewards, coupons, leaderboards, achievements, teams, participants, activities, transactions, emails, triggers, domains, integrations and more.
Two facts shape everything in this codebase:
- It runs against the LIVE shared TogoActive production database (plus the legacy Togoparts database on a second connection). There is no "dev database" — a config write here is instantly visible to the legacy admin, the WL API and the public WL sites.
- It is one of four cooperating apps. Content mutations must actively notify the WL side (cache revalidation), and some features (custom domains, trigger test-sends) are executed by the WL API server on this backend's behalf.
Related docs
- 01-architecture-overview.md — how the five systems and two servers fit together
- 02-admin-frontend.md — the React SPA that consumes this API
- 04-wl-api.md — the public-facing WL API that shares the same database
- 06-database-schema.md — table-level schema reference
1. Role in the platform & the shared-live-DB reality
React admin SPA ──HTTP──▶ admin-backend (/api/v1, Laravel 10)
│
├── mysql → LIVE TogoActive production DB (shared)
├── mysql_tgp → Togoparts (TGP) legacy DB
├── DO Spaces → media uploads (CDN URLs)
└── HTTP → WL API (revalidate, deploy, test-send)Database connections
Defined in config/database.php:
| Connection | Database | Strict mode | Purpose |
|---|---|---|---|
mysql (default) | LIVE TogoActive DB (DigitalOcean managed MySQL) | true | All v3 admin tables + all legacy event/participant/payment tables |
mysql_tgp | Togoparts (TGP) | false | Legacy user accounts, Strava activities, challenge leaderboards |
Models for TGP tables live under App\Models\Tgp\* and set $connection = 'mysql_tgp'. Everything else uses the default connection.
What "shared live DB" means operationally
- The
.envpoints at the production DigitalOcean database — the same one used by the legacy old-admin app at/var/www/togoactiveand by the WL API. The v3 migrations were applied directly to it (migration batch 121). - Config/data edits are instantly live everywhere. Saving an event's appearance here changes what the production WL site renders. There is no promotion step for data.
- Code is deployed per-server. The admin preview and the production WL API run on a separate server; a code change here does nothing there until pushed and deployed. Bugs that only reproduce in one place are usually a stale deploy, not a data problem.
- Because the DB is shared with a legacy app, v3 code must coexist with legacy schema quirks (see Gotchas) rather than "fixing" columns that legacy code still reads.
2. Auth & RBAC
Sanctum with a custom admin guard
- Authentication is Laravel Sanctum personal access tokens, but on a dedicated guard:
auth:adminwith provideradmin_users→App\Models\AdminUser(tableadmin_users). Admin identities are completely separate from participantusers. POST /api/v1/auth/loginverifies the password withHash::checkand requiresis_active; on success it issuescreateToken('admin-token'). Token lifetime comes fromSANCTUM_TOKEN_EXPIRATION(default 1440 minutes / 24 h).- Multiple concurrent tokens are allowed (an admin can be logged in from several browsers).
POST /auth/logoutdeletes only the current access token. GET /auth/mereturns the authenticated admin including resolved permissions. Super admins getpermissions: ['*'].- One route group uses plain
auth:sanctuminstead ofauth:admin: the Code Sync endpoints (server-to-server, different token audience).
Global permissions — admin.can: middleware
CheckAdminPermission (route alias admin.can:<permission>) enforces named permissions from the RBAC tables (admin_permissions ⇄ admin_role_permissions ⇄ admin_roles ⇄ admin_user_roles):
| Situation | Response |
|---|---|
| No authenticated admin | 401 |
Admin exists but is_active = false | 403 |
| Super admin | bypass — always allowed |
| Otherwise | allowed iff hasAnyPermission() for the required key, else 403 |
Permission keys in use: users.view/create/edit/delete, roles.view/manage, dashboard.view, events.view/create/edit. RBAC is seeded by AdminRbacSeeder.
Per-event roles — admin.event middleware
CheckEventAccess (route alias admin.event) gates every /events/{eventId}/... route on membership of that specific event, via the admin_user_events pivot (role column):
- Super admin: full access to every event.
- Non-member:
403on any event-scoped route. - Member, safe method (
GET/HEAD/OPTIONS): allowed for any role, includingviewer. - Member, mutation: requires a write role. A
viewergets a read-only403.
Role constants (on AdminUser / shared helpers):
| Constant | Roles |
|---|---|
EVENT_ROLES | owner, admin, editor, viewer |
WRITE (may mutate event content) | owner, admin, editor |
MANAGE (may manage event members) | owner, admin |
Helpers: eventRole($eventId), canEditEvent(), canManageEventMembers(), canAssignOwner().
Note: the Members & Access routes (
EventMemberController) are declared withadmin.can:events.vieweven for mutations — the finerMANAGE-level checks (canManageEventMembers,canAssignOwner) happen inside the controller, not in route middleware.
So a typical event mutation passes three layers: auth:admin (valid token) → admin.can:events.edit (global permission) → admin.event (member of this event with a write role).
3. Full endpoint reference
All routes live in routes/api.php (~723 lines) under prefix /api/v1. Unless stated otherwise, routes require auth:admin. The Permission column shows admin.can: middleware; event-scoped tables additionally imply admin.event. OpenAPI docs are generated with l5-swagger from #[OA] PHP attributes on the controllers.
3.1 Public & auth
| Method | Path | Controller | Permission |
|---|---|---|---|
| GET | /health | HealthController | public |
| POST | /auth/login | AuthController@login | public |
| GET | /integrations/google/callback | IntegrationController@callback | public (trust via signed OAuth state) |
| POST | /auth/logout | AuthController@logout | auth only |
| GET | /auth/me | AuthController@me | auth only |
3.2 RBAC (users, roles, permissions)
| Method | Path | Controller | Permission |
|---|---|---|---|
| GET | /users | AdminUserController@index | users.view |
| POST | /users | AdminUserController@store | users.create |
| GET | /users/{userId} | AdminUserController@show | users.view |
| PATCH | /users/{userId} | AdminUserController@update | users.edit |
| DELETE | /users/{userId} | AdminUserController@destroy | users.delete |
| PUT | /users/{userId}/events | AdminUserController@assignEvents | users.edit |
| GET | /roles | AdminRoleController@index | roles.view |
| POST | /roles | AdminRoleController@store | roles.manage |
| GET | /roles/{roleId} | AdminRoleController@show | roles.view |
| PATCH | /roles/{roleId} | AdminRoleController@update | roles.manage |
| DELETE | /roles/{roleId} | AdminRoleController@destroy | roles.manage |
| GET | /permissions | AdminPermissionController@index | roles.view |
3.3 Dashboards & analytics
| Method | Path | Controller | Permission |
|---|---|---|---|
| GET | /dashboard/stats | DashboardController@stats | dashboard.view |
| GET | /events/{eventId}/dashboard | DashboardController@eventDashboard | dashboard.view + admin.event |
| GET | /events/{eventId}/analytics | AnalyticsController@show | dashboard.view + admin.event |
AnalyticsController is entirely DB-backed (no fabricated data), fault-tolerant per tab (one failing query does not blank the page), and computes period-over-period deltas. Strava-derived data comes from the Togoparts DB.
3.4 Cross-event participants & activities
No extra permission middleware (auth only):
| Method | Path | Controller |
|---|---|---|
| GET | /participants | ParticipantController@index |
| GET | /participants/stats | ParticipantController@stats |
| GET | /participants/{participantId} | ParticipantController@show |
| PUT | /participants/{participantId} | ParticipantController@update |
| GET | /activities | ActivityController@index |
| GET | /activities/stats | ActivityController@stats |
| GET | /activities/{activityId} | ActivityController@show |
3.5 Events — core setup (EventController unless noted)
| Method | Path | Controller | Permission |
|---|---|---|---|
| GET | /events | EventController@index | events.view |
| GET | /events/check-slug | EventController@checkSlug | auth only |
| POST | /events | EventController@store | events.create |
| GET | /events/{id} | EventController@show | events.view + admin.event |
| PUT | /events/{id}/general | updateGeneral | events.edit + admin.event |
| PUT | /events/{id}/dates | updateDates | events.edit + admin.event |
| PUT | /events/{id}/registration | updateRegistration | events.edit + admin.event |
| GET | /events/{id}/registration/form-schema | getFormSchema | events.view + admin.event |
| PUT | /events/{id}/registration/form-schema | updateFormSchema | events.edit + admin.event |
| PUT | /events/{id}/teams/settings | updateTeamSettings | events.edit + admin.event |
| PUT | /events/{id}/social-seo | updateSocialSeo | events.edit + admin.event |
| PUT | /events/{id}/publish | publish | events.edit + admin.event |
| POST | /events/{id}/images | uploadImages | events.edit + admin.event |
| DELETE | /events/{id}/images/{slot} | deleteImage | events.edit + admin.event |
| PATCH | /events/{id}/images/{slot}/url | setImageUrl | events.edit + admin.event |
| PUT | /events/{id}/menu | updateMenu | events.edit + admin.event |
| GET/PUT | /events/{id}/appearance | AppearanceController | view / edit + admin.event |
Image slots follow a two-step contract: uploading to the bucket does not persist the slot — the frontend must
PATCH .../images/{slot}/urlafterwards.
3.6 Event-scoped content & commerce
All paths below are prefixed /events/{eventId} and run under admin.event; GET routes need events.view, mutations need events.edit (exceptions noted).
Feature settings (see §4) — FeatureSettingController: GET /feature-settings, PUT /feature-settings, POST /feature-settings/reset.
Pages — PageController: GET /pages, GET /pages/{pageId}, POST /pages, PUT /pages/reorder, PUT /pages/{pageId}, DELETE /pages/{pageId}, POST /pages/{pageId}/duplicate, PATCH /pages/{pageId}/status, PATCH /pages/{pageId}/active.
Meta templates (per-page-type SEO/OG with tokens) — MetaTemplateController: GET /meta-templates, GET|PUT|DELETE /meta-templates/{pageType}, GET /meta-tokens/{pageType}.
FAQ / Rules / Landing FAQ — EventFaqController (GET|PUT /faq, GET|PUT /rules) and LandingFaqController (GET|PUT /landing-faq). FAQ Manager (full page, events_faqs) and Landing Page FAQ (landing_page.Short_faq) are separate systems.
Host config — EventHostController: GET /host-config, GET /host-config/search-users, PUT /host-config, POST /host-config/create-user.
Donations & fundraising — DonationConfigController (GET|PUT /donation-config), FundraiserConfigController (GET|PUT /fundraiser-config, backs the Update Goals modal and /fundraiser page).
Rewards — RewardController: GET /rewards, GET /rewards/import-sources, GET /rewards/{rewardId}, POST /rewards, POST /rewards/import, PUT /rewards/reorder (drag sort → sort_id), PUT /rewards/{rewardId}, DELETE /rewards/{rewardId} (FK violation on purchased SKUs → friendly 409), PATCH /rewards/{rewardId}/visibility, PUT /reward-instructions. Literal routes (import-sources, reorder) are deliberately registered before the {rewardId} wildcard.
Coupons — CouponController: GET /coupons, GET /coupons/import-sources, GET|PUT /coupons/default-config (returning-participant default coupon, couponJsonInfo), POST /coupons, POST /coupons/import, PUT|DELETE /coupons/{couponId}.
Reward discounts — RewardDiscountController: GET /reward-discounts, POST /reward-discounts, PUT|DELETE /reward-discounts/{discountId} (table multi_quantity_discount).
Leaderboard — LeaderboardController: GET|PUT /leaderboard/general, GET /leaderboard/tabs + POST /leaderboard/tabs + PUT|DELETE /leaderboard/tabs/{tabId}, GET /leaderboard/highlights + POST + PUT /leaderboard/highlights/reorder + PUT|DELETE /leaderboard/highlights/{highlightId}, GET|PUT /leaderboard/sync.
Achievements — AchievementController / AchievementGroupController: GET /achievements, GET /achievements/{id}, POST, PUT, DELETE, POST /achievements/{id}/duplicate, PATCH /achievements/{id}/visibility; groups: GET /achievement-groups, POST, PUT /achievement-groups/reorder (before wildcard), PUT|DELETE /achievement-groups/{groupId}.
Teams — TeamController: GET /teams, GET /teams/unassigned, PUT /teams/{teamId} (rename — must sync challenge_team_leaderboard.team_name), DELETE /teams/{teamId}, POST /teams/{teamId}/members, DELETE /teams/{teamId}/members/{userId}, PUT /teams/{teamId}/members/{userId}/owner, PUT /teams/{teamId}/members/{userId}/move.
Members & Access — EventMemberController: GET /members, GET /members/candidates, POST /members, PATCH /members/{memberId}, DELETE /members/{memberId} (route middleware is only events.view; MANAGE-role checks are in the controller).
Avatars (per-event) — AvatarController: GET /avatars (event categories male/female/gender_neutral; global categories returned read-only), POST /avatars, PUT /avatars/reorder, PUT /avatars/category-order, DELETE /avatars/{avatarId}.
Custom domain — DomainController: GET /domain, POST /domain, POST /domain/verify-txt, POST /domain/verify-dns, POST /domain/sync (provisions nginx vhost/SSL on the WL server via WlDeployService), DELETE /domain.
Media — MediaController: POST /media/upload — the generic image uploader behind Email Branding logos, email template blocks, the page builder etc. Uploads to DO Spaces and returns a public CDN URL.
3.7 Event-scoped operations (participants / activities / transactions)
Prefixed /events/{eventId}, under admin.event; GET = events.view, mutations = events.edit.
Participants — EventParticipantController (heavy user of DB subquery selects; see Gotchas):
| Method | Path |
|---|---|
| GET | /participants, /participants/stats, /participants/filter-options |
| GET | /participants/export, /export-trx, /export-optout, /export-sku |
| GET | /participants/{id}, /{id}/payments, /{id}/ebib, /{id}/ecert |
| PATCH | /participants/{id} · DELETE /participants/{id} |
| PATCH | /{id}/featured, /{id}/achievements, /{id}/remarks, /{id}/reward-size, /{id}/rewards/{userRewardId}, /{id}/address |
| POST | /participants/{id}/strava-sync, /participants/notify |
Activities — EventActivityController (rows live in TGP challenge_strava_activities, PK caid):
| Method | Path |
|---|---|
| GET | /activities, /activities/stats, /activities/filter-options, /activities/export, /activities/resync |
| GET | /activities/{caid}/audit, /activities/{caid}/detail · POST /activities/batch-audit |
| PATCH | /activities/{caid}/exclude, /activities/{caid}/review |
| POST | /activities/{caid}/remarks, /activities/batch-review, /activities/batch-exclude, /activities/batch-remark, /activities/resync |
Transactions — EventTransactionController: GET /transactions, /transactions/stats, /transactions/filter-options, /transactions/export, /transactions/{paymentId}.
3.8 Communications (emails, triggers, automation)
Prefixed /events/{eventId}; GET = events.view, mutations = events.edit, all + admin.event.
| Area | Routes | Controller |
|---|---|---|
| Email branding | `GET | PUT /email-branding` |
| Email templates | `GET | PUT /email-templates` |
Email logs (read-only audit of every send; reads legacy mail_logs) | GET /email-logs, GET /email-logs/{id}, GET /email-logs/{id}/preview (HTML for iframe) | EmailLogController |
| Default messages (profile bio, share copy, success copy) | `GET | PUT /default-messages` |
| Automation rules (code catalog + per-event overrides) | GET /automation-rules, PUT /automation-rules/{ruleName}, POST /automation-rules/{ruleName}/test | AutomationRuleController |
| Trigger conditions | GET /triggers/conditions, `GET | PATCH /triggers/conditions/{name}, POST .../{name}/enable, POST .../{name}/disable` |
| Trigger sends | POST /triggers/conditions/{name}/send, POST .../{name}/dry-run (view perm), POST /triggers/test-send | TriggerSendController |
| Trigger audit logs | GET /triggers/logs, /logs/{id}, /conditions/{name}/logs, /logs/summary, /logs/user/{userId}/why-not-sent, /logs/export | TriggerAuditLogController |
Trigger routes are defined inline in
routes/api.php(lines ~259–304). Aroutes/triggers.phpduplicate exists but appears to be reference-only / not loaded — do not edit it expecting effect.
3.9 Global (non-event) resources
| Method | Path | Controller | Permission |
|---|---|---|---|
| GET | /payment-gateway-credentials | PaymentGatewayCredentialController@index | events.view |
| POST / PUT / DELETE | /payment-gateway-credentials[/{id}] | same | events.edit |
| GET/PUT | /events/{id}/payment-gateways | PaymentGatewayController | view / edit + admin.event |
| GET | /avatars/global | AvatarController@globalIndex | events.view |
| POST | /avatars/global | globalStore | events.edit |
| PUT | /avatars/global/reorder, /avatars/global/category-order | globalReorder / globalReorderCategories | events.edit |
| DELETE | /avatars/global/{avatarId} | globalDestroy | events.edit |
| POST | /ai/chat | AiController@chat | auth only |
| GET | /ai/providers | AiController@providers | auth only |
The shared credential library stores reusable Stripe (etc.) credential sets in payment_gateway_config; secrets are masked in API responses. Global avatars cover the shared categories male_classic / female_classic / animal (rows with event_id NULL); the global avatar page is intended for super admins.
3.10 Integrations (per event)
Prefixed /events/{eventId}, under admin.event (IntegrationController):
| Method | Path | Permission |
|---|---|---|
| GET | /integrations | events.view |
| GET | /integrations/google/properties | events.view |
| POST | /integrations/google/connect · /select · /create | events.edit |
| DELETE | /integrations/google | events.edit |
| POST / DELETE | /integrations/tag (GTM / Pixel / Contentsquare tag providers) | events.edit |
Plus the public GET /integrations/google/callback (§3.1) that Google redirects the browser to.
3.11 Port Users (event-scoped, destination = {eventId})
PortUserController, prefixed /events/{eventId}/port-users:
| Method | Path | Permission |
|---|---|---|
| GET | /source-events | events.view |
| POST | /audience/preview, /audience/export | events.view |
| GET | /schema-diff | events.view |
| POST | /dry-run | events.view |
| GET | /runs, /runs/{runId} | events.view |
| POST | /runs (execute) | events.edit |
| POST | /runs/{runId}/revert | events.edit |
3.12 Code Sync (server-to-server)
Uses plain auth:sanctum (separate token audience from the admin guard). CodeSyncController: GET /code-sync/status, POST /code-sync/fetch|merge|pull|rebuild|full-sync. Used to fetch/merge code between the two servers.
4. Feature settings system
This is the central mode-aware switchboard that decides which admin sections and WL features exist for a given event. Read this before adding any new feature surface.
4.1 Registry
FeatureRegistry is a code-defined catalogue of ~130+ feature keys in 8 groups:
participants · leaderboard · registration · teams
rewards · achievements · operations · donations(activity_manager.* and transaction_history.* keys are filed under operations.) Each feature declares a boolean default per event mode: [default, seasonal, donation].
4.2 Event modes
events.mode is an enum: default_mode / sessionalmode / donation_mode (a NULL mode is treated as default_mode). The mode expresses what kind of event this is — e.g. event 37 "AMP UP!" is donation_mode — and selects which column of registry defaults applies.
4.3 Overrides & resolution
Per-event overrides live in event_feature_settings:
| Column | Meaning |
|---|---|
event_id, feature_key | which feature of which event |
is_enabled | the overridden value |
is_overridden | true only when the value differs from the mode default |
overridden_by, overridden_at | audit trail |
FeatureService::isEnabled(event, key) resolves as:
- If an override row exists with
is_overridden = true→ useis_enabled. - Otherwise → the registry default for the event's mode.
- Unknown feature key →
true(fail-open, so forgetting to register a key never hides a shipped feature).
Other behaviours:
POST /feature-settings/reset(resetAll) simply deletes the event's override rows, restoring pure mode defaults.seedDefaultsruns at event creation (viaEventSeedService).- Every feature-settings mutation calls
WlCache::flush(eventId)so the WL site revalidates and its gating matches the admin immediately.
4.4 How consumers use it
- The admin frontend receives the resolved map as
event.featureSettingsand gates UI viafeatureGate.js(sidebar stays visible; disabled sections render a banner instead of vanishing). - The WL API reads the same resolution to decide what public functionality is exposed.
- When adding a feature: register the key with per-mode defaults in
FeatureRegistry, gate both admin UI and WL behaviour on it, and never hard-code mode checks in controllers — askFeatureService.
5. Models & tables
Default connection mysql unless in the TGP group. See 06-database-schema.md for columns.
Admin / RBAC
| Model | Table |
|---|---|
AdminUser | admin_users |
AdminRole | admin_roles |
AdminPermission | admin_permissions |
| (pivots) | admin_user_roles, admin_role_permissions, admin_user_events (with role) |
Event core
| Model | Table | Notes |
|---|---|---|
Event | events | hasOne: dates, images, registrationSetup, socialSeo, customDomain, faq. hasMany: participants, teams, rewards, achievements, coupons, meta, payments, pages, featureSettings. m2m admins withPivot role; belongsTo creator. Computed lifecycle_status / publication_status. |
EventDate | events_dates | |
EventImage | event_images | image slots |
EventMeta | events_meta | key/value per event (e.g. reward_instructions, TGP_CHALLENGE_ID) |
EventMetaTemplate | event_meta_templates | per-page-type SEO templates |
EventFaq | events_faqs | |
EventHighlight | event_highlight | blocks / display_settings JSON casts |
EventPage | event_pages | sort_order |
SocialSeo | social_seo | |
Configuration | configuration | key/value blob store (see §6) |
LandingPage | landing_page | incl. Short_faq |
RegistrationSetup | registration_setup | enable_teams/referral/coupon/delivery_address/grouping, allow_re_registration, allow_early_registration, allow_free_registration |
EventDomain | event_domains | custom domains |
EventIntegration | event_integrations | refresh_token encrypted |
EventFeatureSetting | event_feature_settings | |
EventFeaturedProfile | event_featured_profiles | featured/popular flags live here, not event_users |
Participants & teams
| Model | Table | Notes |
|---|---|---|
User | users | participant accounts — column is fullname, not name |
EventUser | event_users | referral_code = registration referral |
EventUserMeta | event_user_meta | |
EventAvatar | event_avatars | event_id NULL = global (male_classic/female_classic/animal); event rows use male/female/gender_neutral |
Team | teams | |
TeamUser | team_users |
Commerce
| Model | Table | Notes |
|---|---|---|
Reward | rewards | |
Customization | customizations | |
Coupon | coupons | |
MultiQuantityDiscount | multi_quantity_discount | |
UserReward | user_rewards | |
Payment | payments | coupon_code = per-payment coupon |
PaymentDetail | payment_details | |
PaymentMethod | payment_method | |
PaymentGatewayCredential | payment_gateway_config | shared credential library |
Donation | donations | |
TaxDeductionDetail | tax_deduction_details | keyed by payment_id |
Gamification & leaderboards
| Model | Table |
|---|---|
Achievement | achievements |
AchievementGroup | achievement_groups |
AchievementCondition | achievement_cron_setup |
ConditionSubcondition | achievement_cron_condition |
ChallengeAchievementWinner | challenge_achievement_winners |
LeaderboardSetting | leaderboard_setting |
EventLeaderboardTab | event_leaderboard_tab |
LeaderboardSortingTab | leaderboard_sorting_tab |
EventLeaderboardActivity | event_leaderboard_activities |
Trigger / automation / ops
| Model | Table |
|---|---|
ConditionRegistry | condition_registry |
EventTriggerSetting | event_trigger_settings |
TriggerSystemConfig | trigger_system_config |
EmailTemplate | email_templates |
EmailTemplateAuditLog | email_template_audit_logs |
TriggerAuditLog | trigger_audit_logs |
PortRun / PortRunItem | port_runs / port_run_items |
CronJob / CronExecution / CronFailure | cron_jobs / cron_executions / cron_failures |
StravaActivity | strava_activity |
TGP (App\Models\Tgp\*, connection mysql_tgp)
| Model | Table | Notes |
|---|---|---|
TgpUser | users | PK userid |
ChallengeLeaderboard | challenge_leaderboard | |
ChallengeTeamLeaderboard | challenge_team_leaderboard | denormalised team_name |
ChallengeStravaActivity | challenge_strava_activities | PK caid |
ChallengeStravaActivityAudit | challenge_strava_activity_audit |
challenge_donation_leaderboardhas no model here — it is owned/handled by the WL API side. (event_registrationandevent_upgradeare not tables at all — they are keys in theconfigurationblob store; see 06-database-schema.md §3.3.)
6. The configuration key/value store pattern
The legacy configuration table (model Configuration) is a per-event key → JSON blob store. v3 leans on it for several settings surfaces:
configuration.key | Backs |
|---|---|
email_branding | Communications → Email Branding |
payment_gateways | Setup → Payment Gateways (per-event selection) |
event_donation | Donation config |
event_fundraiser | Fundraiser goals/presets/deadline |
event_default_message | Default copy: bios, share text, success-page copy, and any new admin copy fields |
Convention — one config per concern. When you need a new admin-editable copy/messaging field, extend the event_default_message blob (add a key inside it) rather than minting a sibling configuration.key row. Spinning new top-level keys fragments the store, complicates WL-side resolution, and has been explicitly rejected in review.
Related merge gotcha: list-valued settings inside these blobs (e.g. fundraiser preset chips) must be merged list-aware — array_replace_recursive resurrects deleted array items; use the deepMerge approach that replaces lists wholesale.
7. Migrations & schema history
All v3 additions are 2026-dated migrations applied to the live DB (batch 121). Grouped chronology of what v3 added on top of the legacy schema:
- RBAC:
admin_users;admin_roles+admin_user_roles;admin_permissions+admin_role_permissions;admin_user_events(later +rolecolumn). - Event platform:
social_seo(later made nullable — beware NOT-NULL caveats on older rows);event_pages(nav/footer flags added then dropped; later +url_pattern, +user_detail_donationpage type);event_domains;event_feature_settings;event_meta_templates;blockscolumn onevent_highlight;event_avatars+event_avatar_categories;created_byonevents;event_integrations(+tag_id);event_seo_templateonevents. - Registration gates:
allow_re_registration, thenallow_early_registration, thenallow_free_registrationonregistration_setup. - Port Users:
port_runs,port_run_items, andport_run_idcolumns onevent_users/event_user_meta/team_users/payments(revert tracking). - Automation & triggers:
event_automation_rules;cron_jobs/cron_executions/cron_failures;condition_registry;email_templates+ audit;trigger_system_config/event_trigger_settings/trigger_audit_logs.
Seeders: AdminRbacSeeder, DuplicateEventDataSeeder, SeedSg61SeoTemplatesSeeder, TriggerConditionsSeeder, UpdateSg61UserProfileSeoSeeder.
Rules of engagement: migrations here run against production — write them defensively (guard with Schema::hasColumn, keep them reversible) and remember the legacy app and WL API read the same tables while you migrate.
8. Services catalogue
Under app/Services (plus Support/, Domain/, Jobs/, Console/):
| Service | Responsibility |
|---|---|
FeatureRegistry / FeatureService | Mode-aware feature flags (see §4) |
EventSeedService | Seeds a new event's menu, default pages and feature defaults at creation |
ParticipantRemovalService | Safe participant deletion (cascading cleanup) |
GoogleAnalyticsService | GA4 OAuth flow, property listing/creation, measurement setup |
TgpMappingService | Maps TGA events → Togoparts challenge ids (static map + events_meta.TGP_CHALLENGE_ID) |
WlDeployService | Calls the WL server to provision nginx vhosts + SSL for custom domains |
AutomationRuleService | Automation rule resolution; proxies test-sends to the WL API (the WL side owns the real send pipeline) |
TriggerManagementService + TriggerManagement/* | Trigger engine: ConditionQueryBuilder, EmailTemplateService, ValidationService, TriggerService, ConditionRegistryService, DeduplicationService |
PortUsers/PortExecutor, PortUsers/PortReverter | Execute / revert port runs (see §11) |
Ai/AiService + AnthropicProvider / OpenAiProvider / GeminiProvider | AI page-builder chat, provider-pluggable via config/ai.php |
Support/WlCache | Best-effort WL revalidation ping after content mutations (WL_REVALIDATE_* env). Failures are swallowed — never let a cache ping break a save. |
Domain/Automation/RuleDefinition + RuleCatalog | Code-defined automation rule catalogue; per-event overrides in event_automation_rules |
Console (app/Console): scheduler is currently commented out (the trigger:send crons are a TODO); commands include BaseCronCommand, SeedEventMenuAndPages, TriggerManagementCron. Jobs: ExecutePortRun.
Storage: DO Spaces disk do; uploads land under uploads/events/{eventId}/{folder}/ and return CDN URLs.
Email defaults: config/email_templates.php holds default templates that are merged beneath DB overrides from email_templates.
9. Integrations & external bridges
Google Analytics (GA4)
OAuth-based connect flow in IntegrationController + GoogleAnalyticsService: connect starts OAuth, the public /integrations/google/callback receives Google's redirect (trust via signed state, no bearer token), then properties/select/create manage the GA4 property. Tokens are stored in event_integrations with the refresh_token encrypted. Tag-based providers (GTM, Meta Pixel, Contentsquare) use the simpler POST|DELETE /integrations/tag. The WL API resolves these into rendered analytics tags (resolveAnalytics → WL Analytics.tsx).
WL bridge (env keys in .env)
| Key | Used by | Purpose |
|---|---|---|
WL_REVALIDATE_URL / WL_REVALIDATE_SECRET | Support/WlCache | Ping WL to revalidate cached event content after mutations |
WL_DEPLOY_URL / WL_DEPLOY_TOKEN | WlDeployService | Provision nginx vhost + SSL on the WL server for custom domains |
WL_API_URL / WL_INTERNAL_TOKEN | AutomationRuleService etc. | Server-to-server calls into the WL API (e.g. email test-send) |
Remember the two-server topology: the admin frontend also calls the WL API directly for email preview and donation pickers (VITE_WL_API_URL) — a stale WL deploy shows up as "admin-only" bugs.
DigitalOcean Spaces
Media uploads go through MediaController@upload / event image endpoints to the do disk and come back as static.togoactive.com CDN URLs. Note the avatar/CDN routing rule: profile_img values starting with uploads/ belong to the TogoActive DO bucket, not the Togoparts CDN.
AI providers
POST /ai/chat routes through Ai/AiService with pluggable providers (Anthropic, OpenAI, Gemini) configured in config/ai.php; GET /ai/providers lists what is configured. Powers the AI page-builder assistant in the admin.
10. Trigger / automation system
Two related but distinct layers:
Automation rules (lightweight)
- Rules are defined in code (
Domain/Automation/RuleCatalogofRuleDefinitions); per-event enable/config overrides live inevent_automation_rules. - API:
GET /automation-rules,PUT /automation-rules/{ruleName},POST /automation-rules/{ruleName}/test— the test-send is proxied to the WL API, which owns real email delivery.
Trigger management (full engine)
condition_registry— catalogue of trigger conditions (seeded byTriggerConditionsSeeder).event_trigger_settings— per-event enable/disable + configuration of each condition.trigger_system_config— global engine settings.email_templates+email_template_audit_logs— per-condition templates with change audit; code defaults fromconfig/email_templates.phpmerge beneath DB rows.trigger_audit_logs— every evaluation/send attempt; queryable per condition, per user (why-not-sent), summarised, exportable.- Services under
TriggerManagement/:ConditionQueryBuilder(turns a condition into an audience query),DeduplicationService(never send the same trigger twice),ValidationService,TriggerService,EmailTemplateService,ConditionRegistryService. - API surface: conditions list/show/patch/enable/disable; send / dry-run / test-send; six audit-log read endpoints (see §3.8).
Current scheduler state: the Laravel scheduler entries for trigger:send are commented out in app/Console — the engine is exercised via the manual send / dry-run / test-send endpoints and TriggerManagementCron, but nothing fires automatically yet. Treat "wire up the cron" as pending work; don't assume production triggers are live.
11. Port Users system
Copies participants (and their meta, teams, payments context) from a source event into the destination event {eventId}, with full auditability and reversibility:
- Explore:
GET /port-users/source-events,POST /audience/preview(+/audience/export) to define and inspect the audience;GET /schema-diffshows how the source event's registration schema differs from the destination's. - Rehearse:
POST /dry-runsimulates the port without writing. - Execute:
POST /runscreates aport_runsrow and dispatches theExecutePortRunjob; progress is polled viaGET /runs/{runId}. Every created row is recorded inport_run_items, and the ported rows themselves are stamped with aport_run_idcolumn (event_users,event_user_meta,team_users,payments). - Undo:
POST /runs/{runId}/revert(PortReverter) deletes exactly what the run created, using those stamps.
Services: PortUsers/PortExecutor, PortUsers/PortReverter. Preview/dry-run need only events.view; execute/revert need events.edit.
12. Gotchas & conventions
whereColumndoes not work inside eager-loading constraints. Use DB subquery selects instead —EventParticipantControlleris the canonical example (it uses subquery selects heavily for per-row aggregates).users.fullname, notusers.name. The participant users table predates Laravel conventions.- Featured/popular flags live in
event_featured_profiles, not onevent_users. - Two different "codes":
event_users.referral_codeis the registration referral;payments.coupon_codeis the per-payment coupon. Don't conflate them in queries or exports. - Team rename must sync
challenge_team_leaderboard.team_name(TGP DB) — it is denormalised there and the WL leaderboard reads it. - Image slots are two-phase: bucket upload alone does not persist; the slot URL must be PATCHed.
- Route ordering matters: literal segments (
/rewards/reorder,/coupons/default-config,/achievement-groups/reorder,/rewards/import-sources) are registered before their{id}wildcards on purpose — keep that order when adding routes. - Config blobs: extend
event_default_messagefor new copy fields (one config per concern); merge list values list-aware, neverarray_replace_recursive. - Feature mutations must flush WL cache (
WlCache::flush) or the public site serves stale gating. - Unknown feature keys resolve to enabled — register keys in
FeatureRegistrybefore gating on them, or the gate silently no-ops open. mysqlis strict,mysql_tgpis not — TGP-side queries tolerate legacy sloppiness (zero dates, implicit group-by) that the default connection would reject.- You are on production data. Batch reads, avoid unnecessary live-DB round-trips, and treat every mutation path as customer-visible.