Skip to main content

Overview

/admin is a separate, non-org-scoped surface for AquaOps’ own staff, not for customer organizations. It has its own layout (src/app/admin/layout.tsx) with no org switcher and no org-scoped navigation, and it is intentionally invisible to everyone else: a non-staff user who hits any /admin route gets a silent redirect to /dashboard, with no 403 page or other trace that the surface exists. Three tools live under it today:

Access control

Every /admin page and server action gates on requirePlatformStaff() (src/lib/actions/admin.ts), which throws — never silently no-ops — if the caller isn’t platform staff. The layout instead calls the non-throwing checkIsPlatformStaff() wrapper to decide whether to redirect. A caller counts as platform staff if either is true:
  • their account email ends in @aquaops.dev (checked in application code only, not via RLS)
  • they have a row in public.platform_staff, keyed by auth.uid() and checked through the security-definer helper private.is_platform_staff()
platform_staff has no client-readable RLS policy at all — membership can only be checked with the service-role admin client (never the RLS-scoped one), which is what requirePlatformStaff() uses. The table also has no INSERT/UPDATE/DELETE policy and no grant/revoke UI yet; new staff rows are added by hand via direct SQL against the linked Supabase project. Platform staff still sign up and hold ordinary org membership like any other user — staff status is additive, not a replacement account type. Separately from the /admin write surface, staff also get read-only cross-org visibility via additive for select-only RLS policies (private.is_platform_staff()) on a fixed set of customer tables: organizations, users, organization_memberships, locations, pools, chemical_records, maintenance_logs, closure_incidents, corrections, and form_submissions. This is deliberately not every table — only what’s needed to diagnose the support issues that actually come up (a customer’s data looks wrong, a record won’t save, permissions seem off). There is no corresponding write policy anywhere; the one exception (organization billing fields) goes through a dedicated server action instead, described below.

Audit logging

Every /admin read or write that touches organization data writes a row to platform_staff_audit_log (logAuditEvent() in src/lib/actions/admin.ts), in the same action that performed the read or write — never a separate “remember to log this” step. The log call is awaited and its error is thrown, not swallowed, so an action that can’t be logged doesn’t silently succeed. Logged actions include view_org_list, view_org, edit_org_billing, view_curricula, create_curriculum, edit_curriculum, delete_curriculum, create_curriculum_level, edit_curriculum_level, delete_curriculum_level, view_feature_flags, create_feature_flag, edit_feature_flag, set_feature_flag_org_override, and remove_feature_flag_org_override. platform_staff_audit_log is append-only, with the same read-only-to-staff RLS pattern as platform_staff itself.

Curriculum Templates

Route: /admin/curricula (list) and /admin/curricula/[id] (detail). Actions: src/lib/actions/admin-curricula.ts. This is the authoring side of the swim-lesson curriculum catalog that organizations browse and adopt from on the org-facing side (see Swim Lessons — “Adopting a curriculum”). Org admins can browse and adopt from the catalog but cannot add or edit templates in it; template authoring is exclusively a platform-staff task here. Each template (curriculum_templates) has a name, optional description, optional source attribution (e.g. “American Red Cross, 2014 edition”), and an is_active flag — inactive templates stay in the catalog for staff but are not offered to orgs for adoption. A template owns an ordered list of levels (curriculum_template_levels), each with a name, sort_order, and a skill list ({ id, label }[] stored as JSON). From the list page, staff can create a new template (New curriculum). From a template’s detail page they can:
  • edit the template’s name, description, source attribution, and active flag
  • add, edit, or delete levels and their skills (New level, inline skill add/remove per level, per-level save)
  • delete the whole template, via a confirm dialog
Delete behavior: deleting a template cascades its curriculum_template_levels rows, but does not touch any organization’s own lesson-level data. An org level that was live-linked to a deleted template level just has its source_template_level_id set to null (ON DELETE SET NULL) — the org keeps its skills content, it simply stops tracking a now-gone template. This matches the confirm dialog’s copy on the delete button. Adoption is fork-on-write, not a live link. When an org adopts a template (from their own Settings page, not from here), matching is by position (sort_order): each of the org’s levels at a given position is updated in place with the template level’s name and skills at adoption time. After that it’s a one-time copy — editing a template here does not retroactively change any org that already adopted it; the org would need to re-adopt (re-browse and re-apply) to pick up changes. An org level the org has since edited by hand, or built from scratch and never linked to a template, is left alone on adopt and re-adopt. This entire adoption/fork mechanism lives in the org-facing action layer (src/lib/actions/lesson-classes.ts), not in the admin action file — the admin side only ever writes to curriculum_templates / curriculum_template_levels, never to an org’s own lesson_levels.

Feature Flags

Route: /admin/feature-flags (list) and /admin/feature-flags/[id] (detail). Actions: src/lib/actions/admin-feature-flags.ts. Resolution logic: src/lib/services/feature-flags.ts.
There are two unrelated things named “feature flags” in this codebase. This admin tool manages the database-backed feature_flags / organization_feature_flags tables described below — the ones actually wired into the app. src/lib/features.ts is a separate, static, plan-tier-keyed FeatureKey map (custom_forms / compliance_exports / kiosk_mode) that its own file comment states is “deliberately not wired into any route or component yet.” Don’t confuse the two when tracing how a feature is gated — only the feature_flags table below is live.
Each flag (feature_flags) has a key (lowercase letters/numbers/underscores, immutable after creation — it’s the literal string app code checks), a display name, an optional description, and is_enabled_by_default. Per-organization overrides live in organization_feature_flags (organization_id, feature_flag_id, is_enabled), one row per org per flag. Resolution (isFeatureEnabled / getEnabledFeatureKeys in src/lib/services/feature-flags.ts): an org-specific override always wins; otherwise the flag’s global is_enabled_by_default applies. A key with no matching row at all fails open (treated as enabled) — an unregistered or not-yet-seeded flag should never silently hide a feature. This logic is called from the app shell’s nav visibility, require-admin.ts’s module gates, and directly from alerts.ts / exports.ts (those two have no dedicated PermissionKey namespace of their own, so they’re checked by feature key rather than derived from a permission prefix). The flaggable key set today is APP_FEATURE_KEYS: chemical_records, maintenance_logs, compliance, alerts, exports, custom_forms, swim_lessons — one per top-level nav module that isn’t core account/org configuration. From the list page, staff can create a new flag (New feature flag) — the key field is locked after creation for exactly the reason above (renaming it out from under existing isFeatureEnabled(key, ...) call sites would silently break gating, since they’d hit the fail-open “unregistered flag” branch instead of erroring). From a flag’s detail page, staff can:
  • edit name, description, and the global default (key excluded — shown read-only)
  • add a per-org override (pick an org, set enabled/disabled) — the org picker only offers orgs that don’t already have an override
  • toggle an existing override’s enabled state inline, or remove it entirely (removing an override reverts that org to following the global default)

Organization Management

Route: /admin (list) and /admin/organizations/[id] (detail). Actions: src/lib/actions/admin.ts. The org list shows every organization on the platform with plan tier, billing status, seat limit, live Clerk member count, and creation date. Membership counts come from a single bulk Clerk API call (clerkClient().organizations.getOrganizationList) rather than a Postgres join, since membership itself is Clerk’s, not the database’s. The detail page for one organization shows:
  • location count, pool count
  • member count and active-member count (active-member is cross-referenced against public.users.is_active for the org’s Clerk member IDs)
  • seat limit
  • chemical records and maintenance logs created in the last 7 days
  • a billing panel for plan_tier, billing_status, and billing_customer_id
Every read here (view_org_list, view_org) is audit-logged, same as the mutation.

Billing panel

plan_tier / billing_status / seat_limit are the one deliberate exception to /admin’s otherwise read-only posture over customer data — they’re platform-owned billing fields, not customer-authored data. Since the Clerk Billing migration, these fields sync automatically via Clerk Billing webhook events once an organization is on a paid plan — the panel’s own copy states this and that most orgs still show unsynced defaults today because only the Free plan is currently configured. The manual edit form here (EditOrgBillingForm, plan tier / billing status / seat limit, calling updateOrganizationBilling) exists as a fallback, for orgs the webhook hasn’t (yet) synced or for platform-side corrections — not as the primary way billing state gets set. Every edit through this form does a before/after diff and writes an edit_org_billing audit row, then revalidates both /admin and the org’s detail page. billing_customer_id itself is displayed but has no edit control in the form — it’s populated only by the webhook sync path.
  • Swim Lessons — the org-facing side of curriculum adoption
  • Architecture — general action-layer and Supabase conventions these admin actions also follow (server actions under src/lib/actions, service-role access stays server-only)
  • Data, Security, and Tenancy — the broader permission and tenant-scoping model that /admin deliberately sits outside of