Admin App — Behavior
Authentication
- Clerk-managed. The SPA initializes
ClerkProviderwithVITE_CLERK_PUBLISHABLE_KEYand falls back to a hash-routed<SignIn />when signed out. - Refine
authProviderwrapsuseAuth();getToken()is threaded into every API call via the custom dataProvider. - SPA gate: Clerk is shared with
apps/web, so a Clerk session alone proves nothing.authProvider.checkcallsGET /admin/session(a@PlatformAdmin()probe) before rendering any route. On 403 it signs the user out and lands on/login?reason=forbidden(“Platform admin access required”). On a network error or 5xx it fails closed to/login?reason=unavailable. It always signs out first, because/loginsends signed-in users straight back into the app, which would loop. - Errors surface: the dataProvider throws Refine
HttpError({ message, statusCode }) on non-2xx responses, so 401/403 reachauthProvider.onErrorand don’t render as empty tables. - Authorization (the real boundary): every
/admin/**API call passes throughPlatformAdminGuard(apps/api/src/admin/guards/platform-admin.guard.ts).apps/api/src/admin/__tests__/admin-routes-platform-admin.unit.spec.tsfails CI if any handler on anadmin/**controller lacks it. The guard:- Read
clerkIdfromrequest.auth.userId. - Cache lookup
platform-admin:<clerkId>in Redis (5 min TTL). - On cache miss, fetch the Clerk user and check
privateMetadata.platformRole === 'admin'. - Cache the result (
trueorfalse) for 5 min. - Throw
ForbiddenExceptionotherwise.
- Read
A user demoted in Clerk continues to have access for up to 5 minutes — acceptable for a tool that’s used by trusted internal staff only. The cache key is invalidated manually via the admin user detail page when a demotion needs to take effect immediately.
Audit logging
Every state-changing call to /admin/** flows through AdminAuditInterceptor. The interceptor:
- Captures actor
clerkId, method, URL, body. - Writes to
audit_logswithactorClerkId,action,resource,resourceId,metadata.{ adminAction: true, ip, userAgent, ... }. - Pipes the entry through
EventTrackingServiceto PostHog so admin actions show on the same funnel as agent actions.
Reads are not audit-logged.
Pages
Organizations
GET /admin/organizations — list with pagination, filters (tier, status, country, search).
GET /admin/organizations/:id — full detail including members, subscriptions, plans, minisite status, recent audit entries.
Tier updates: POST /admin/organizations/:id/tier body { tier }, audited.
Users
GET /admin/users — cross-org user search.
GET /admin/users/:id — profile, memberships, payment cards.
Detail also shows emailOwnership (proven / unproven, read from Clerk metadata; null when the user has no Clerk account). Actions: password reset (POST /admin/users/:id/reset-password), soft-delete, force email update, and Mark email verified (POST /admin/users/:id/verify-email-ownership, shown when unproven, disabled for deleted users) — writes emailProvenAt / emailProvenBy='admin' / emailProvenByActor=<admin Clerk id> to the Clerk user so held invitations can be accepted and an existing-account owner grant is allowed. 404 for a soft-deleted user. Use it only after confirming out-of-band that the person controls the inbox.
Owner Invitations
Only way to let someone create a gym (POST /organizations requires a claimable grant). Page /owner-invitations; table owner_invitations.
GET /admin/owner-invitations?page&limit&status&search— list (email, status, account/invitation type, email-ownership tag, organization, expires, created).POST /admin/owner-invitations{ email, existingAccount }:existingAccount=false(default, “Invite owner”): the email must have no Clerk account (409 OWNER_INVITATION_EMAIL_HAS_ACCOUNTotherwise). Inserts apendinggrant (expires in 30 days) and sends a Clerk invitation (notify: true, redirect/sign-up,publicMetadata.ownerInvitationId).existingAccount=true(“Existing account” switch): binds the grant to the linked account’s Clerk id; no email is sent.404 OWNER_INVITATION_ACCOUNT_NOT_FOUNDwhen no linked account;409 OWNER_INVITATION_EMAIL_UNPROVENwhen the account has not proven email ownership (fail closed — mark the email verified on the user first). The API message is shown as an antd error toast.409 OWNER_INVITATION_PENDING_EXISTSwhen a live pending grant already exists for the email. Enforced by a partial unique index (owner_invitations_pending_email_uq, onependingrow per email), so two concurrent creates cannot both succeed; a lapsedpendingrow is markedexpiredfirst.
POST /admin/owner-invitations/:id/resend— new-email grants only; mints and stores the new Clerk invitation first, then revokes the old one (so theinvitation.revokedecho matches no grant);expires_at = now + 30 days.DELETE /admin/owner-invitations/:id— revokes a pending grant (best-effort Clerk revoke). Revoking the Clerk invitation from the Clerk Dashboard is mirrored by theinvitation.revokedwebhook.
One grant creates one organization. Runbook: docs/runbooks/owner-invitations.md.
Payments + subscriptions
- Payment list filterable by provider, status, org, date range.
- Refund flow requires
reasonin the body — recorded into the audit metadata. - Subscription dashboard surfaces
next_charge_dateand overdue cohorts.
Leads
Cross-org lead pipeline. Owners reassign leads to orgs (e.g. a waitlist lead asking for a specific gym).
Jobs
Lists imports, exports, embedding, AI enrichment jobs across all orgs. Pause / requeue / drop helpers.
Audit Logs
Filterable by actor, resource, timestamp, metadata.agent, metadata.adminAction. The view is the canonical “what happened” timeline.
Queues
BullMQ status (waiting, active, completed, failed, delayed counts). Actions: pause, resume, drain. Powered by AdminQueuesService.
System
Server uptime, memory, Redis ping, Postgres + R2 health, env summary (no secrets).
Costs
Daily and monthly spend across providers (Anthropic, Voyage, Resend, etc.). Per-org breakdown surfaces cost outliers.
Billing inspection
Per-org invoice and balance state. Surfaces debt, past-due, cancellation requests.
Platform Billing
Taikan’s own income side. Lists platform transactions (apps/api/src/admin/controllers/admin-platform-billing.controller.ts), MRR, tier distribution, cancelled platform subscriptions. Includes a cancel-platform-txn.dto.ts action for refunding Taikan’s invoices.
Observability
Agent trace pivot, recent Sentry issues (linked out), PostHog quick links. Lightweight — heavy analysis lives in the underlying tools.
Actions
One-off operational hooks under apps/api/src/admin/controllers/admin-actions.controller.ts:
- Rebuild cache (
rebuild-cache.dto.ts). - Reindex embeddings (
reindex.dto.ts). - Fix bookings (
fix-bookings.dto.ts). - Manual cost entry (
manual-cost.dto.ts). - Each is audited.
Impersonate
POST /admin/impersonate/:userId returns a short-lived Clerk impersonation token. Admin loads the dashboard as the target user; the audit log carries impersonatedUserId. Used sparingly for ticket resolution.
Canonical Movements
Page for curating the canonical exercise library (used by the embedding + Spotter resolve flows). Editors review enrichment output, edit muscle/equipment tags, dedupe duplicates.
Deploy
- Vercel project, separate from
apps/web. - Build:
pnpm exec nx build admin-app→vite build→ SPA assets underdist/. - Hosted at a private subdomain; access is gated at app level (Clerk) — no IP allowlist.
Limits
- No mobile layout — Ant Design table density assumes desktop.
- Real-time updates (queues, jobs) poll every 10s; no SSE / WebSocket.
- Bulk operations are limited to what the API exposes; no client-side select-all + apply pattern beyond what Refine ships.