Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesAdmin AppAdmin App — Behavior

Admin App — Behavior

Authentication

  • Clerk-managed. The SPA initializes ClerkProvider with VITE_CLERK_PUBLISHABLE_KEY and falls back to a hash-routed <SignIn /> when signed out.
  • Refine authProvider wraps useAuth(); 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.check calls GET /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 /login sends 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 reach authProvider.onError and don’t render as empty tables.
  • Authorization (the real boundary): every /admin/** API call passes through PlatformAdminGuard (apps/api/src/admin/guards/platform-admin.guard.ts). apps/api/src/admin/__tests__/admin-routes-platform-admin.unit.spec.ts fails CI if any handler on an admin/** controller lacks it. The guard:
    1. Read clerkId from request.auth.userId.
    2. Cache lookup platform-admin:<clerkId> in Redis (5 min TTL).
    3. On cache miss, fetch the Clerk user and check privateMetadata.platformRole === 'admin'.
    4. Cache the result (true or false) for 5 min.
    5. Throw ForbiddenException otherwise.

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_logs with actorClerkId, action, resource, resourceId, metadata.{ adminAction: true, ip, userAgent, ... }.
  • Pipes the entry through EventTrackingService to 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_ACCOUNT otherwise). Inserts a pending grant (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_FOUND when no linked account; 409 OWNER_INVITATION_EMAIL_UNPROVEN when 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_EXISTS when a live pending grant already exists for the email. Enforced by a partial unique index (owner_invitations_pending_email_uq, one pending row per email), so two concurrent creates cannot both succeed; a lapsed pending row is marked expired first.
  • POST /admin/owner-invitations/:id/resend — new-email grants only; mints and stores the new Clerk invitation first, then revokes the old one (so the invitation.revoked echo 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 the invitation.revoked webhook.

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 reason in the body — recorded into the audit metadata.
  • Subscription dashboard surfaces next_charge_date and 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 under dist/.
  • 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.