Skip to content

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:

  1. 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.
  2. 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.

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:

ConnectionDatabaseStrict modePurpose
mysql (default)LIVE TogoActive DB (DigitalOcean managed MySQL)trueAll v3 admin tables + all legacy event/participant/payment tables
mysql_tgpTogoparts (TGP)falseLegacy 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 .env points at the production DigitalOcean database — the same one used by the legacy old-admin app at /var/www/togoactive and 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:admin with provider admin_usersApp\Models\AdminUser (table admin_users). Admin identities are completely separate from participant users.
  • POST /api/v1/auth/login verifies the password with Hash::check and requires is_active; on success it issues createToken('admin-token'). Token lifetime comes from SANCTUM_TOKEN_EXPIRATION (default 1440 minutes / 24 h).
  • Multiple concurrent tokens are allowed (an admin can be logged in from several browsers). POST /auth/logout deletes only the current access token.
  • GET /auth/me returns the authenticated admin including resolved permissions. Super admins get permissions: ['*'].
  • One route group uses plain auth:sanctum instead of auth: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_permissionsadmin_role_permissionsadmin_rolesadmin_user_roles):

SituationResponse
No authenticated admin401
Admin exists but is_active = false403
Super adminbypass — always allowed
Otherwiseallowed 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: 403 on any event-scoped route.
  • Member, safe method (GET/HEAD/OPTIONS): allowed for any role, including viewer.
  • Member, mutation: requires a write role. A viewer gets a read-only 403.

Role constants (on AdminUser / shared helpers):

ConstantRoles
EVENT_ROLESowner, 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 with admin.can:events.view even for mutations — the finer MANAGE-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

MethodPathControllerPermission
GET/healthHealthControllerpublic
POST/auth/loginAuthController@loginpublic
GET/integrations/google/callbackIntegrationController@callbackpublic (trust via signed OAuth state)
POST/auth/logoutAuthController@logoutauth only
GET/auth/meAuthController@meauth only

3.2 RBAC (users, roles, permissions)

MethodPathControllerPermission
GET/usersAdminUserController@indexusers.view
POST/usersAdminUserController@storeusers.create
GET/users/{userId}AdminUserController@showusers.view
PATCH/users/{userId}AdminUserController@updateusers.edit
DELETE/users/{userId}AdminUserController@destroyusers.delete
PUT/users/{userId}/eventsAdminUserController@assignEventsusers.edit
GET/rolesAdminRoleController@indexroles.view
POST/rolesAdminRoleController@storeroles.manage
GET/roles/{roleId}AdminRoleController@showroles.view
PATCH/roles/{roleId}AdminRoleController@updateroles.manage
DELETE/roles/{roleId}AdminRoleController@destroyroles.manage
GET/permissionsAdminPermissionController@indexroles.view

3.3 Dashboards & analytics

MethodPathControllerPermission
GET/dashboard/statsDashboardController@statsdashboard.view
GET/events/{eventId}/dashboardDashboardController@eventDashboarddashboard.view + admin.event
GET/events/{eventId}/analyticsAnalyticsController@showdashboard.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):

MethodPathController
GET/participantsParticipantController@index
GET/participants/statsParticipantController@stats
GET/participants/{participantId}ParticipantController@show
PUT/participants/{participantId}ParticipantController@update
GET/activitiesActivityController@index
GET/activities/statsActivityController@stats
GET/activities/{activityId}ActivityController@show

3.5 Events — core setup (EventController unless noted)

MethodPathControllerPermission
GET/eventsEventController@indexevents.view
GET/events/check-slugEventController@checkSlugauth only
POST/eventsEventController@storeevents.create
GET/events/{id}EventController@showevents.view + admin.event
PUT/events/{id}/generalupdateGeneralevents.edit + admin.event
PUT/events/{id}/datesupdateDatesevents.edit + admin.event
PUT/events/{id}/registrationupdateRegistrationevents.edit + admin.event
GET/events/{id}/registration/form-schemagetFormSchemaevents.view + admin.event
PUT/events/{id}/registration/form-schemaupdateFormSchemaevents.edit + admin.event
PUT/events/{id}/teams/settingsupdateTeamSettingsevents.edit + admin.event
PUT/events/{id}/social-seoupdateSocialSeoevents.edit + admin.event
PUT/events/{id}/publishpublishevents.edit + admin.event
POST/events/{id}/imagesuploadImagesevents.edit + admin.event
DELETE/events/{id}/images/{slot}deleteImageevents.edit + admin.event
PATCH/events/{id}/images/{slot}/urlsetImageUrlevents.edit + admin.event
PUT/events/{id}/menuupdateMenuevents.edit + admin.event
GET/PUT/events/{id}/appearanceAppearanceControllerview / 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}/url afterwards.

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.

PagesPageController: 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 FAQEventFaqController (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 configEventHostController: GET /host-config, GET /host-config/search-users, PUT /host-config, POST /host-config/create-user.

Donations & fundraisingDonationConfigController (GET|PUT /donation-config), FundraiserConfigController (GET|PUT /fundraiser-config, backs the Update Goals modal and /fundraiser page).

RewardsRewardController: 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.

CouponsCouponController: 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 discountsRewardDiscountController: GET /reward-discounts, POST /reward-discounts, PUT|DELETE /reward-discounts/{discountId} (table multi_quantity_discount).

LeaderboardLeaderboardController: 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.

AchievementsAchievementController / 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}.

TeamsTeamController: 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 & AccessEventMemberController: 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 domainDomainController: 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.

MediaMediaController: 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):

MethodPath
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):

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

AreaRoutesController
Email branding`GETPUT /email-branding`
Email templates`GETPUT /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)`GETPUT /default-messages`
Automation rules (code catalog + per-event overrides)GET /automation-rules, PUT /automation-rules/{ruleName}, POST /automation-rules/{ruleName}/testAutomationRuleController
Trigger conditionsGET /triggers/conditions, `GETPATCH /triggers/conditions/{name}, POST .../{name}/enable, POST .../{name}/disable`
Trigger sendsPOST /triggers/conditions/{name}/send, POST .../{name}/dry-run (view perm), POST /triggers/test-sendTriggerSendController
Trigger audit logsGET /triggers/logs, /logs/{id}, /conditions/{name}/logs, /logs/summary, /logs/user/{userId}/why-not-sent, /logs/exportTriggerAuditLogController

Trigger routes are defined inline in routes/api.php (lines ~259–304). A routes/triggers.php duplicate exists but appears to be reference-only / not loaded — do not edit it expecting effect.

3.9 Global (non-event) resources

MethodPathControllerPermission
GET/payment-gateway-credentialsPaymentGatewayCredentialController@indexevents.view
POST / PUT / DELETE/payment-gateway-credentials[/{id}]sameevents.edit
GET/PUT/events/{id}/payment-gatewaysPaymentGatewayControllerview / edit + admin.event
GET/avatars/globalAvatarController@globalIndexevents.view
POST/avatars/globalglobalStoreevents.edit
PUT/avatars/global/reorder, /avatars/global/category-orderglobalReorder / globalReorderCategoriesevents.edit
DELETE/avatars/global/{avatarId}globalDestroyevents.edit
POST/ai/chatAiController@chatauth only
GET/ai/providersAiController@providersauth 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):

MethodPathPermission
GET/integrationsevents.view
GET/integrations/google/propertiesevents.view
POST/integrations/google/connect · /select · /createevents.edit
DELETE/integrations/googleevents.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:

MethodPathPermission
GET/source-eventsevents.view
POST/audience/preview, /audience/exportevents.view
GET/schema-diffevents.view
POST/dry-runevents.view
GET/runs, /runs/{runId}events.view
POST/runs (execute)events.edit
POST/runs/{runId}/revertevents.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:

ColumnMeaning
event_id, feature_keywhich feature of which event
is_enabledthe overridden value
is_overriddentrue only when the value differs from the mode default
overridden_by, overridden_ataudit trail

FeatureService::isEnabled(event, key) resolves as:

  1. If an override row exists with is_overridden = true → use is_enabled.
  2. Otherwise → the registry default for the event's mode.
  3. 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.
  • seedDefaults runs at event creation (via EventSeedService).
  • 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.featureSettings and gates UI via featureGate.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 — ask FeatureService.

5. Models & tables

Default connection mysql unless in the TGP group. See 06-database-schema.md for columns.

Admin / RBAC

ModelTable
AdminUseradmin_users
AdminRoleadmin_roles
AdminPermissionadmin_permissions
(pivots)admin_user_roles, admin_role_permissions, admin_user_events (with role)

Event core

ModelTableNotes
EventeventshasOne: 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.
EventDateevents_dates
EventImageevent_imagesimage slots
EventMetaevents_metakey/value per event (e.g. reward_instructions, TGP_CHALLENGE_ID)
EventMetaTemplateevent_meta_templatesper-page-type SEO templates
EventFaqevents_faqs
EventHighlightevent_highlightblocks / display_settings JSON casts
EventPageevent_pagessort_order
SocialSeosocial_seo
Configurationconfigurationkey/value blob store (see §6)
LandingPagelanding_pageincl. Short_faq
RegistrationSetupregistration_setupenable_teams/referral/coupon/delivery_address/grouping, allow_re_registration, allow_early_registration, allow_free_registration
EventDomainevent_domainscustom domains
EventIntegrationevent_integrationsrefresh_token encrypted
EventFeatureSettingevent_feature_settings
EventFeaturedProfileevent_featured_profilesfeatured/popular flags live here, not event_users

Participants & teams

ModelTableNotes
Userusersparticipant accounts — column is fullname, not name
EventUserevent_usersreferral_code = registration referral
EventUserMetaevent_user_meta
EventAvatarevent_avatarsevent_id NULL = global (male_classic/female_classic/animal); event rows use male/female/gender_neutral
Teamteams
TeamUserteam_users

Commerce

ModelTableNotes
Rewardrewards
Customizationcustomizations
Couponcoupons
MultiQuantityDiscountmulti_quantity_discount
UserRewarduser_rewards
Paymentpaymentscoupon_code = per-payment coupon
PaymentDetailpayment_details
PaymentMethodpayment_method
PaymentGatewayCredentialpayment_gateway_configshared credential library
Donationdonations
TaxDeductionDetailtax_deduction_detailskeyed by payment_id

Gamification & leaderboards

ModelTable
Achievementachievements
AchievementGroupachievement_groups
AchievementConditionachievement_cron_setup
ConditionSubconditionachievement_cron_condition
ChallengeAchievementWinnerchallenge_achievement_winners
LeaderboardSettingleaderboard_setting
EventLeaderboardTabevent_leaderboard_tab
LeaderboardSortingTableaderboard_sorting_tab
EventLeaderboardActivityevent_leaderboard_activities

Trigger / automation / ops

ModelTable
ConditionRegistrycondition_registry
EventTriggerSettingevent_trigger_settings
TriggerSystemConfigtrigger_system_config
EmailTemplateemail_templates
EmailTemplateAuditLogemail_template_audit_logs
TriggerAuditLogtrigger_audit_logs
PortRun / PortRunItemport_runs / port_run_items
CronJob / CronExecution / CronFailurecron_jobs / cron_executions / cron_failures
StravaActivitystrava_activity

TGP (App\Models\Tgp\*, connection mysql_tgp)

ModelTableNotes
TgpUserusersPK userid
ChallengeLeaderboardchallenge_leaderboard
ChallengeTeamLeaderboardchallenge_team_leaderboarddenormalised team_name
ChallengeStravaActivitychallenge_strava_activitiesPK caid
ChallengeStravaActivityAuditchallenge_strava_activity_audit

challenge_donation_leaderboard has no model here — it is owned/handled by the WL API side. (event_registration and event_upgrade are not tables at all — they are keys in the configuration blob 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.keyBacks
email_brandingCommunications → Email Branding
payment_gatewaysSetup → Payment Gateways (per-event selection)
event_donationDonation config
event_fundraiserFundraiser goals/presets/deadline
event_default_messageDefault 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-awarearray_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 + role column).
  • 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_donation page type); event_domains; event_feature_settings; event_meta_templates; blocks column on event_highlight; event_avatars + event_avatar_categories; created_by on events; event_integrations (+ tag_id); event_seo_template on events.
  • Registration gates: allow_re_registration, then allow_early_registration, then allow_free_registration on registration_setup.
  • Port Users: port_runs, port_run_items, and port_run_id columns on event_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/):

ServiceResponsibility
FeatureRegistry / FeatureServiceMode-aware feature flags (see §4)
EventSeedServiceSeeds a new event's menu, default pages and feature defaults at creation
ParticipantRemovalServiceSafe participant deletion (cascading cleanup)
GoogleAnalyticsServiceGA4 OAuth flow, property listing/creation, measurement setup
TgpMappingServiceMaps TGA events → Togoparts challenge ids (static map + events_meta.TGP_CHALLENGE_ID)
WlDeployServiceCalls the WL server to provision nginx vhosts + SSL for custom domains
AutomationRuleServiceAutomation 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/PortReverterExecute / revert port runs (see §11)
Ai/AiService + AnthropicProvider / OpenAiProvider / GeminiProviderAI page-builder chat, provider-pluggable via config/ai.php
Support/WlCacheBest-effort WL revalidation ping after content mutations (WL_REVALIDATE_* env). Failures are swallowed — never let a cache ping break a save.
Domain/Automation/RuleDefinition + RuleCatalogCode-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)

KeyUsed byPurpose
WL_REVALIDATE_URL / WL_REVALIDATE_SECRETSupport/WlCachePing WL to revalidate cached event content after mutations
WL_DEPLOY_URL / WL_DEPLOY_TOKENWlDeployServiceProvision nginx vhost + SSL on the WL server for custom domains
WL_API_URL / WL_INTERNAL_TOKENAutomationRuleService 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/RuleCatalog of RuleDefinitions); per-event enable/config overrides live in event_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 by TriggerConditionsSeeder).
  • 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 from config/email_templates.php merge 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:

  1. Explore: GET /port-users/source-events, POST /audience/preview (+ /audience/export) to define and inspect the audience; GET /schema-diff shows how the source event's registration schema differs from the destination's.
  2. Rehearse: POST /dry-run simulates the port without writing.
  3. Execute: POST /runs creates a port_runs row and dispatches the ExecutePortRun job; progress is polled via GET /runs/{runId}. Every created row is recorded in port_run_items, and the ported rows themselves are stamped with a port_run_id column (event_users, event_user_meta, team_users, payments).
  4. 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

  • whereColumn does not work inside eager-loading constraints. Use DB subquery selects instead — EventParticipantController is the canonical example (it uses subquery selects heavily for per-row aggregates).
  • users.fullname, not users.name. The participant users table predates Laravel conventions.
  • Featured/popular flags live in event_featured_profiles, not on event_users.
  • Two different "codes": event_users.referral_code is the registration referral; payments.coupon_code is 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_message for new copy fields (one config per concern); merge list values list-aware, never array_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 FeatureRegistry before gating on them, or the gate silently no-ops open.
  • mysql is strict, mysql_tgp is 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.

Organiser guide and developer documentation for the TogoActive platform.