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 byauth.uid()and checked through the security-definer helperprivate.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
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.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_activefor 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, andbilling_customer_id
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.
Related reading
- 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
/admindeliberately sits outside of