> ## Documentation Index
> Fetch the complete documentation index at: https://docs.aquaops.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Platform Admin Tools

> Internal /admin surface for AquaOps staff — curriculum template authoring, feature flag management, and cross-org support tooling.

## 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:

| Tool | Route | Purpose |
| - | - | - |
| Organizations | `/admin` | Cross-org support/billing panel |
| Curricula | `/admin/curricula` | Swim-lesson curriculum template catalog |
| Feature Flags | `/admin/feature-flags` | Global + per-org feature flag management |

## 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](https://docs.aquaops.dev/user-guide/swim-lessons.md) — "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`.

> [!NOTE]
>
> 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.

## Related reading

* [Swim Lessons](https://docs.aquaops.dev/user-guide/swim-lessons.md) — the org-facing side of curriculum adoption
* [Architecture](https://docs.aquaops.dev/developer-docs/architecture.md) — 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](https://docs.aquaops.dev/developer-docs/data-security-and-tenancy.md) — the broader
  permission and tenant-scoping model that `/admin` deliberately sits outside of


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.