<!--
Admin Console Playbook

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# Admin Console Playbook

**Portable spec for shipping a SaaS admin console: customer + workspace management, plan/credit ops, impersonation, audit log, a unified conversation system (support tickets + sales CRM inbox + personal team mailboxes), email-drip automation, a financial P&L overview, realtime "cha-ching" notifications, Web Push, compliance + credit-pricing config, enterprise API key bridge, and refunds.**

Built from the BKE / Kompozy implementation. Reference repo: `reference app`.

> **What this playbook owns vs. what it references.** The admin console is the *cockpit* that sits on top of several engines. Where another playbook owns the engine, this one only documents the **admin surface** and links out:
> - Email templates/campaigns/enrollments engine → [EMAIL-INFRASTRUCTURE-PLAYBOOK](EMAIL-INFRASTRUCTURE-PLAYBOOK.md). Admin "Automations" UI is here.
> - First-party event tracking + the analytics *dashboard* (ranges, funnels, channel classifier, Active-Now globe, per-page drill-down) → [ADMIN-ANALYTICS-DASHBOARD-PLAYBOOK](../a-tier/ADMIN-ANALYTICS-DASHBOARD-PLAYBOOK.md) + [CUSTOMER-ANALYTICS-PLAYBOOK](../a-tier/CUSTOMER-ANALYTICS-PLAYBOOK.md). Admin "Analytics" tab mounts that surface.
> - SEO/GEO trend discovery + auto-author engine → [SEO-GEO-ENGINE-PLAYBOOK](../a-tier/SEO-GEO-ENGINE-PLAYBOOK.md). Admin "SEO & GEO" tab is its console.
> - Credit ledger + spend/refund RPCs → [CREDIT-METERING-PLAYBOOK](CREDIT-METERING-PLAYBOOK.md). Admin "Credits" settings + grant/refund UI sit on top.
> - Per-output brand-safety blocks → [AI-COMPLIANCE-GATE-PLAYBOOK](../c-tier/AI-COMPLIANCE-GATE-PLAYBOOK.md). Admin "Compliance" matrix edits its config.

---

## 0. Use this spec

**Inputs:**
- Existing auth stack (Supabase or equivalent) with a tenant table — `profiles` (per-user apps) **or** `workspaces` (multi-tenant apps). **Pick your admin-flag carrier deliberately** (see §5).
- A `SUPABASE_SERVICE_ROLE_KEY` env var (never exposed client-side)
- `IMPERSONATION_SECRET` env var (32+ char random) for signing side-channel cookies
- Decision: which admin actions need a typed reason vs free-form metadata
- Stripe (or billing provider) keys if refunds, pause-billing, and the P&L overview are in scope. **If billing is deferred, ship everything except §11/§20/§21 and stub MRR=$0.**
- (Optional) `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` for Web Push, Postmark for the conversation system's outbound email.

**Outputs:**
- `/admin` (public login) + `/admin/dashboard/*` (admin-only) routes
- Admin gating at proxy + API + DB layers (role on `profiles`, or `is_admin` on `workspaces`)
- `admin_actions` audit table — every destructive write logged
- Impersonation flow: server-minted scoped session, banner in user view, max 1h TTL
- **Financial overview** — MRR/ARR/LTV, windowed revenue/expense/net-margin, acquisition funnel, plan distribution, P&L, top accounts
- **Unified conversation system** — support tickets + sales-CRM inbox + personal team mailboxes on one `conversation_messages` core, with realtime "cha-ching" notifications + Web Push
- **Funnel segmentation** — signups split lead-vs-abandoned-checkout; anonymous lead-magnet opt-ins tiered by fit + engagement
- **Email automation console** (drip campaigns, templates, enrollments, analytics)
- **Settings** — enterprise API keys, compliance matrix, credit pricing, onboarding videos, revenue/expense ledgers
- Account-status transitions: `active → suspended → cancelled → archived`
- Admin → Client sync invariant via `updateCustomer()` → `syncCustomerToClient()`

---

## 1. Architecture

```
src/
├── proxy.ts                              # ADMIN_PROTECTED_PREFIXES gating (Next 16: proxy.ts, not middleware.ts)
│
├── lib/
│   ├── admin.ts                          # Customer/workspace CRUD + sync + getEffectiveApiKeys + role/permission helpers
│   ├── admin-auth.ts                     # requireAdmin() for /api/admin/*
│   ├── admin-audit.ts                    # recordAdminAction()
│   ├── admin-impersonation.ts            # Signed side-channel cookie HMAC
│   ├── admin-keys-server.ts              # Enterprise key resolver (server)
│   ├── conversations/                    # Shared messaging core (tickets + leads + mail)
│   │   ├── mailboxes.ts                  #   has_mailbox_access, listMailboxesForUser
│   │   ├── threads.ts                    #   list/get threads, read-marks, labels, star/archive/spam/trash
│   │   └── messages.ts                   #   append message, attachments, inbound vs outbound
│   └── metrics/
│       ├── stats.ts                      #   windowed MRR/ARR/LTV/churn/funnel snapshot
│       └── lifetime.ts                   #   monotonic lifetime counters (shared with landing HUD)
│
├── app/
│   ├── admin/
│   │   ├── page.tsx                      # /admin — public login form
│   │   └── dashboard/
│   │       ├── layout.tsx                # Admin-shell + role gate + nav + <AdminNotifications/> + <AdminPushSetup/>
│   │       ├── page.tsx                  # Overview — financial P&L + acquisition funnel + top accounts
│   │       ├── accounts/                 # Customer/workspace list + detail
│   │       ├── signups/                  # Lead vs abandoned-checkout segmentation
│   │       ├── leads/                    # Cold lead-magnet opt-ins (tiered)
│   │       ├── users/                    # User search / admin-user roster
│   │       ├── support/                  # Support ticket inbox + [id] workspace
│   │       ├── inbox/                     # Sales-CRM lead inbox + [id] LeadWorkspace
│   │       ├── mail/                      # Personal team mailboxes + [id] MailWorkspace
│   │       ├── email/                     # Email-drip automation console (campaigns/templates/enrollments/analytics/layout)
│   │       ├── analytics/                 # Mounts ADMIN-ANALYTICS-DASHBOARD surface (ActiveNow globe, reports, page-detail)
│   │       ├── seo-geo/                   # Mounts SEO-GEO-ENGINE console (discovery + analytics)
│   │       ├── settings/                  # Profile, Revenue, Expenses, API keys, Compliance, Credits, Onboarding
│   │       └── _components/               # AdminNotifications, AdminPushSetup, MessageAttachments
│   │
│   └── api/admin/
│       ├── customers/[id]/route.ts       # GET detail / PATCH plan/credits
│       ├── customers/[id]/action/route.ts# pause/resume/cancel/refund/grant-credits
│       ├── stats/route.ts                # windowed dashboard metrics
│       ├── revenue|expenses/route.ts     # P&L ledgers (+ POST/DELETE manual entries)
│       ├── signups|leads/route.ts        # funnel segmentation
│       ├── email/**                      # campaign/template/enrollment CRUD (see EMAIL-INFRASTRUCTURE)
│       ├── mail|support|inbox/**         # conversation system endpoints
│       ├── attachments/[id]/route.ts     # admin-gated signed URL (view|download)
│       ├── push/{subscribe,unsubscribe,test}/route.ts
│       ├── compliance|credit-pricing|onboarding-videos/route.ts  # settings panels
│       ├── impersonate/{start,stop}/route.ts
│       └── keys/route.ts                 # Enterprise key CRUD
```

Reference: `src/app/admin/`, `src/lib/admin.ts`, `src/app/api/admin/`.

---

## 2. Nav structure (the cockpit)

The BKE dashboard sidebar, in order — gate each item on the caller's permission (§5):

| # | Nav item | Route | Gated on | Badge |
|---|----------|-------|----------|-------|
| 1 | **Summary** | `/admin/dashboard` | any admin | — |
| 2 | **Accounts** | `/admin/dashboard/accounts` | `canManageAccounts` | — |
| 3 | **Signups** | `/admin/dashboard/signups` | `canManageAccounts` | — |
| 4 | **Cold Leads** | `/admin/dashboard/leads` | `canManageAccounts` | — |
| 5 | **Users** | `/admin/dashboard/users` | `canManageUsers` | — |
| 6 | **Support** | `/admin/dashboard/support` | any admin | unread tickets |
| 7 | **Inbox** (sales CRM) | `/admin/dashboard/inbox` | any admin | unclaimed leads |
| 8 | **Mail** (personal) | `/admin/dashboard/mail` | mailbox access | unread |
| 9 | **Automations** (email) | `/admin/dashboard/email` | any admin | — |
| 10 | **Analytics** | `/admin/dashboard/analytics` | any admin | — |
| 11 | **SEO & GEO** | `/admin/dashboard/seo-geo` | any admin | — |
| 12 | **Settings** | `/admin/dashboard/settings` | super_admin for billing tabs | — |

Scope to your app: a per-user tool (no sales motion) can drop Inbox/Mail; a pre-revenue internal app can drop Automations/SEO and stub the P&L. Ship Summary + Accounts + Users + Audit + Impersonation as the irreducible core.

---

## 3. Schema (Supabase)

### 3a. Admin gating + audit

```sql
-- PER-USER apps: admin role on profiles
alter table profiles add column if not exists role text
  default 'user' check (role in ('user','admin'));

-- MULTI-TENANT apps: server-only admin flag on the workspace (Trackyr pattern).
-- The flag is service-role-write-only; the `authenticated` role can never set it.
alter table workspaces add column if not exists is_admin boolean not null default false;

-- Account status — lifecycle the admin console drives
alter table profiles add column if not exists account_status text
  default 'active' check (account_status in ('active','suspended','billing_paused','cancelled','archived'));
alter table profiles add column if not exists billing_paused_at timestamptz;

-- Audit log (every destructive admin write)
create table admin_actions (
  id          uuid primary key default gen_random_uuid(),
  admin_id    uuid references auth.users(id) on delete set null,  -- nullable: survive admin departures
  customer_id uuid not null references auth.users(id) on delete cascade,
  action      text not null check (action in (
    'pause_billing','resume_billing','pause_account','resume_account',
    'cancel_subscription','refund','delete_account','plan_change','credits_grant',
    'toggle_admin','impersonate'
  )),
  reason      text,
  metadata    jsonb not null default '{}'::jsonb,
  created_at  timestamptz not null default now()
);
create index idx_admin_actions_customer on admin_actions(customer_id, created_at desc);
create index idx_admin_actions_admin    on admin_actions(admin_id, created_at desc);
alter table admin_actions enable row level security;
-- Admins SELECT all; no INSERT policy = service-role only
create policy admin_actions_select on admin_actions for select to authenticated
  using (exists (select 1 from profiles where id = auth.uid() and role = 'admin'));

-- Impersonation audit
create table admin_impersonations (
  id             uuid primary key default gen_random_uuid(),
  admin_user_id  uuid not null references auth.users(id) on delete cascade,
  target_user_id uuid not null references auth.users(id) on delete cascade,
  target_email   text,
  started_at     timestamptz not null default now(),
  ended_at       timestamptz,
  ip text, user_agent text
);
create index idx_imp_active on admin_impersonations(admin_user_id) where ended_at is null;
alter table admin_impersonations enable row level security;
create policy imp_admin_select on admin_impersonations for select
  using (exists (select 1 from profiles p where p.id = auth.uid() and p.role = 'admin'));
```

### 3b. Financial P&L (§7)

```sql
-- Stripe revenue ledger (mirrored from webhooks; never the source of truth, just fast reads)
create table admin_revenue (
  id uuid primary key default gen_random_uuid(),
  customer_id uuid references auth.users(id) on delete set null,
  kind text not null check (kind in ('subscription','addon','credit_pack','refund','other')),
  amount_cents int not null,          -- negative for refunds
  plan text, stripe_invoice_id text,
  occurred_at timestamptz not null default now()
);
-- API/vendor cost ledger (auto-logged per generation + manual entries)
create table admin_expenses (
  id uuid primary key default gen_random_uuid(),
  vendor text not null,               -- 'anthropic' | 'openai' | 'apify' | 'heygen' | ...
  amount_cents int not null,
  note text, occurred_at timestamptz not null default now()
);
-- Monotonic lifetime counters (shared with the public landing HUD; never decrements)
create table lifetime_metrics (
  key text primary key,               -- 'posts_published' | 'contacts_discovered' | ...
  value bigint not null default 0,
  updated_at timestamptz not null default now()
);
```

### 3c. Unified conversation system (§10–§14)

One messaging core powers support tickets, the sales CRM inbox, and personal team mailboxes. **`mailboxes` is the routing root; `conversation_messages` is polymorphic on `(parent_type, parent_id)`.**

```sql
-- Routing root: an addressable mailbox (support@, sales@, enterprise@, moe@, david@)
create table mailboxes (
  id uuid primary key default gen_random_uuid(),
  kind text not null check (kind in ('support','sales','info','personal')),
  address text not null unique,        -- 'support@app.com'
  display_name text,
  owner_user_id uuid references auth.users(id) on delete set null  -- set for 'personal'
);

-- Access helper used by every conversation RLS policy + API guard
create or replace function has_mailbox_access(p_mailbox uuid, p_uid uuid, p_mode text)
returns boolean language sql stable security definer set search_path = public as $$
  select exists (
    select 1 from mailboxes m
    where m.id = p_mailbox
      and ( m.owner_user_id = p_uid                                  -- personal owner
            or exists (select 1 from profiles where id = p_uid and role='admin') )  -- shared = any admin
  );
$$;

-- Tickets (support), leads (sales CRM), mail_threads (personal) — three thin parent tables
create table tickets (
  id uuid primary key default gen_random_uuid(),
  mailbox_id uuid references mailboxes(id),
  requester_email text, subject text,
  status text not null default 'open' check (status in ('open','in_progress','resolved','closed')),
  priority text default 'normal',
  assigned_admin_id uuid references auth.users(id),
  unread_message_count int not null default 0,
  last_message_at timestamptz, created_at timestamptz not null default now()
);
create table leads (
  id uuid primary key default gen_random_uuid(),
  mailbox_id uuid references mailboxes(id),
  name text, email text,
  status text not null default 'new' check (status in ('new','qualified','demo_booked','proposal','won','lost')),
  estimated_value_cents int,
  assigned_admin_id uuid references auth.users(id),
  unread_message_count int not null default 0,
  last_message_at timestamptz, created_at timestamptz not null default now()
);
create table mail_threads (
  id uuid primary key default gen_random_uuid(),
  mailbox_id uuid not null references mailboxes(id) on delete cascade,
  subject text, counterparty_email text, counterparty_name text,
  last_activity_at timestamptz, last_inbound_at timestamptz,   -- inbound drives "unread"
  read_at timestamptz,                                          -- SHARED read mark (all admins see one state)
  starred boolean default false, archived_at timestamptz,
  label text, spam boolean default false, trashed_at timestamptz,
  created_at timestamptz not null default now()
);

-- The shared message + event stream (polymorphic parent)
create table conversation_messages (
  id uuid primary key default gen_random_uuid(),
  parent_type text not null check (parent_type in ('ticket','lead','mail_thread')),
  parent_id uuid not null,
  direction text not null check (direction in ('inbound','outbound')),
  author_user_id uuid references auth.users(id),  -- null for inbound
  body_html text, body_text text,
  attachments jsonb not null default '[]'::jsonb,  -- [{storage_path, filename, mime, size}]
  created_at timestamptz not null default now()
);
create index idx_convmsg_parent on conversation_messages(parent_type, parent_id, created_at);
create table conversation_events (   -- read/assign/status-change audit
  id uuid primary key default gen_random_uuid(),
  parent_type text not null, parent_id uuid not null,
  event text not null, actor_user_id uuid references auth.users(id),
  metadata jsonb default '{}'::jsonb, created_at timestamptz not null default now()
);
```

### 3d. Funnel segmentation (§9–§10)

```sql
-- Distinguish "lead" (signed up, never reached checkout) vs "abandoned checkout"
alter table profiles add column if not exists checkout_started_at timestamptz;
alter table profiles add column if not exists stripe_customer_id text;
-- (BKE migration 00150: lead_abandoned_checkout_tracking)

-- Anonymous lead-magnet opt-ins, tiered HOT/WARM/COLD by fit + engagement (BKE 00164)
create table magnet_leads (
  id uuid primary key default gen_random_uuid(),
  email text not null, magnet_slug text,              -- which squeeze variant (A/B)
  qualifier text,                                     -- the niche answer
  source text, utm_source text, utm_campaign text,
  visitor_id text, session_id text,
  fit_score int default 0, engagement_score int default 0,  -- tier = f(fit, engagement)
  nurture_step int default 0,
  nurture_status text default 'active' check (nurture_status in ('active','completed','exited')),
  next_email_at timestamptz,
  user_id uuid references auth.users(id), converted_at timestamptz,  -- set when they sign up
  created_at timestamptz not null default now()
);
```

### 3e. Settings + push

```sql
-- Enterprise API keys (admin-managed, override user keys when betaMode off — see §20)
create table enterprise_api_keys (
  slot_id text primary key, purpose text not null check (purpose in ('text','image','video','audio')),
  provider text not null, api_key text not null,
  selected_model text default '', selected_image_model text default '',
  status text not null default 'untested' check (status in ('untested','valid','invalid')),
  updated_at timestamptz default now()
);
create table enterprise_api_config (id int primary key default 1, beta_mode boolean not null default true, check (id=1));

-- Per-format credit pricing (Settings → Credits; see CREDIT-METERING-PLAYBOOK)
create table credit_pricing (
  format text primary key,
  provider_cost_usd numeric not null, calls_per_generation int not null default 1,
  infra_cost_usd numeric not null default 0, margin_multiplier numeric not null default 2.0,
  updated_at timestamptz default now()
);
-- credits = max(1, ceil(((provider_cost * calls) + infra) * multiplier / unit_usd))

-- Admin Web Push subscriptions (§24)
create table push_subscriptions (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  endpoint text not null unique, p256dh text not null, auth text not null,
  created_at timestamptz not null default now()
);

-- All settings + push tables: RLS on, NO policies = service-role-only. Admin reads via /api/admin/*.
alter table enterprise_api_keys enable row level security;
alter table enterprise_api_config enable row level security;
alter table credit_pricing enable row level security;
alter table push_subscriptions enable row level security;
```

Reference migrations: `00046_admin_actions_audit.sql`, `00042_admin_impersonations.sql`, 00065-00070 (email), 00131-00139 (mail), 00150 (signup segmentation), 00152 (ticket unread), 00164 (magnet leads).

---

## 4. Edge gating (proxy.ts)

```ts
const ADMIN_PROTECTED_PREFIXES = ['/admin/dashboard', '/api/admin'];
// /admin (login page) stays public — only /admin/dashboard/* is gated.
```

In `proxy.ts` (Next 16 renamed `middleware.ts` → `proxy.ts`), after the standard `supabase.auth.getUser()` check, resolve the admin flag for any path under `ADMIN_PROTECTED_PREFIXES` and redirect non-admins to `/admin` (UI) or return 403 (API).

**Hot-path optimization:** if your auth provider supports custom JWT claims, mint the admin flag into the access token and read `user.app_metadata` instead of round-tripping to Postgres on every request. Otherwise cache the lookup in edge KV with a 60s TTL. (BKE lab note: `profiles.role` in RLS = N+1 per row; prefer a JWT claim or a SECURITY DEFINER helper — see §27.)

---

## 5. `requireAdmin()` + the role/permission model

Every `/api/admin/*` route's first line is `const ctx = await requireAdmin(); if (isAdminResponse(ctx)) return ctx;`. It returns `{ userId, adminClient }` (service-role) **only after** the admin check passes against the caller's session-bound client.

Reference: `src/lib/admin-auth.ts`.

```ts
// PER-USER apps (profiles.role):
export async function requireAdmin(): Promise<AdminContext | NextResponse> {
  const ssr = await createServerSupabase();
  const { data: { user } } = await ssr.auth.getUser();
  if (!user) return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
  const { data: profile } = await ssr.from('profiles').select('role').eq('id', user.id).maybeSingle();
  if (profile?.role !== 'admin') return NextResponse.json({ error: 'admin role required' }, { status: 403 });
  return { userId: user.id, adminClient: createAdminSupabase() };
}

// MULTI-TENANT apps (workspaces.is_admin) — Trackyr pattern:
//   ...resolve the caller's owned workspace, check is_admin === true.
//   The session-bound client does the read (RLS-friendly); service-role escapes only after the gate.
```

**Permission tiers (optional, BKE ships these).** Beyond a binary admin flag, layer roles for least-privilege:

```ts
type AdminRole = 'super_admin' | 'admin' | 'viewer';
const canManageUsers    = (r: AdminRole) => r !== 'viewer';
const canManageAccounts = (r: AdminRole) => r !== 'viewer';
const canEditBilling    = (r: AdminRole) => r === 'super_admin';   // Revenue/Expenses/Refunds
```

Gate destructive billing surfaces (refunds, expense edits, plan overrides) on `super_admin`; let `viewer` see dashboards read-only. The session-bound client does the check; the service-role client only escapes after the gate.

---

## 6. Audit log — `recordAdminAction()`

Every destructive or billing-affecting admin write calls `recordAdminAction()` AFTER the real change succeeds. Best-effort: never throw, never block the real action's response.

Reference: `src/lib/admin-audit.ts`.

```ts
await recordAdminAction({
  adminId: ctx.userId, customerId: targetId,
  action: 'plan_change', reason: body.reason,
  metadata: { from: oldPlan, to: newPlan },
});
```

Allowed actions: `pause_billing`, `resume_billing`, `pause_account`, `resume_account`, `cancel_subscription`, `refund`, `delete_account`, `plan_change`, `credits_grant`, `toggle_admin`, `impersonate`. Extend the CHECK constraint when adding new ones. Customer detail reads the timeline via `GET /api/admin/customers/[id]/admin-actions` — paginated, scoped to that customer.

---

## 7. Overview dashboard — financial P&L + acquisition funnel

`/admin/dashboard` is the cockpit home. A **timeframe toggle (7D / 30D / 90D / ALL)** windows every cash/churn metric. Data comes from `GET /api/admin/stats?days=`, `/api/admin/revenue`, `/api/admin/expenses`, and the lifetime counters at `/api/metrics/live`.

Reference: `src/app/admin/dashboard/page.tsx`.

**Card rows (BKE layout):**
1. **Financials** — MRR, ARR, LTV, windowed REVENUE (cash in window), EXPENSES (vendor costs in window), NET MARGIN, ADD-ON REVENUE.
2. **Acquisition** — TOTAL CUSTOMERS, NEW CUSTOMERS, NEW PAID, PAID CHURN, TRIAL CHURN, CONVERSION RATE (signup→paid).
3. **Funnel segments** — LEADS (no checkout), ABANDONED CHECKOUTS, BETA/COMPED, CHURNED, LEGACY.
4. **Checkout funnel viz** — Signed Up → Reached Checkout → Purchased, with step-drop %.
5. **Lifetime metrics** — monotonic counters from `lifetime_metrics` (e.g. POSTS PUBLISHED; for a lead app: CONTACTS DISCOVERED, HUNTS RUN, LEADS PULLED).
6. **Plan distribution** — stacked bar across tiers.
7. **Revenue by plan** + **Expense by vendor** — MRR run-rate per plan; windowed API costs per vendor.
8. **P&L summary** — Subscription + Add-on/Credit-pack + Other − Expenses = NET.
9. **Top accounts table** — name, company, plan, usage stat, publish/activity rate, last active.

`PLAN_MRR` (plan → monthly cents map) drives MRR/ARR/revenue-by-plan. **If billing is deferred, render the P&L cards with $0 and lead with the lifetime/usage rows** — the funnel + top-accounts table are valuable pre-revenue.

---

## 8. Customer/workspace management endpoints

Wire one consistent shape under `/api/admin/customers/[id]/`:

| Endpoint | Verb | Purpose |
|----------|------|---------|
| `GET /api/admin/customers` | GET | Roster (search, plan filter, status filter) |
| `GET /api/admin/customers/[id]` | GET | Full detail: profile, plan, credits, status, health metrics, recent activity |
| `PATCH /api/admin/customers/[id]` | PATCH | name/email/company/plan/credits → `updateCustomer()` → `syncCustomerToClient()` |
| `POST /api/admin/customers/[id]/action` | POST | `{ action, reason }` — pause/resume/cancel/refund/grant-credits/toggle-admin — dispatches + audits |
| `GET /api/admin/customers/[id]/admin-actions` | GET | Paginated audit timeline for this customer |
| `GET /api/admin/customers/[id]/credits` | GET | Credit ledger (grants + transactions) |
| `GET /api/admin/customers/[id]/charges` | GET | Stripe charges (if billing live) |
| `GET /api/admin/customers/[id]/stats` | GET | Per-account usage stats |
| `GET /api/admin/customers/[id]/team-members` | GET | Workspace members (multi-tenant) |

`updateCustomer()` is the single write surface → `syncCustomerToClient()` mirrors to the client's source of truth (§26). For multi-tenant apps, "customer" = workspace; grant credits via the `grant_credits()` RPC, toggle `is_admin` via service role only.

---

## 9. Signups segmentation (lead vs abandoned checkout)

`/admin/dashboard/signups` splits unpaid signups into **LEADS** (signed up, never reached checkout) vs **ABANDONED CHECKOUTS** (reached Stripe, didn't pay) using `checkout_started_at`/`stripe_customer_id` signals. Tabs: ALL / LEADS / ABANDONED. Each row shows created/last-sign-in/checkout time, a "Login As" button, and **recovery status** (which drip campaign + next send). Powers the abandoned-checkout recovery loop (see EMAIL-INFRASTRUCTURE). Endpoint: `GET /api/admin/signups`.

Reference: `src/app/admin/dashboard/signups/page.tsx`.

---

## 10. Cold leads (anonymous lead-magnet opt-ins)

`/admin/dashboard/leads` shows email opt-ins from `/get/[slug]` squeeze pages, scored **HOT / WARM / COLD** by `fit_score + engagement_score`. Filter by tier + magnet source; search by name/email/qualifier. Each row shows the magnet variant received, engagement (pricing views, CTA clicks, distinct active days, checkout-started), nurture status, and a PII-blur toggle. When a lead signs up, `magnet_leads.user_id`/`converted_at` link them to the funnel. Endpoint: `GET /api/admin/leads`. Backed by `magnet_leads` (BKE 00164).

---

## 11. Unified conversation system (one core, three faces)

Support tickets, the sales CRM inbox, and personal team mail are **the same messaging core** — `conversation_messages` polymorphic on `(parent_type, parent_id)`, routed through `mailboxes`, gated by `has_mailbox_access()`. Build the core once; the three UIs are thin parent-table views. The shared `MessageAttachments` component + `/api/admin/attachments/[id]` signed-URL route serve all three.

Reference libs: `mailboxes.ts`, `threads.ts`, `messages.ts`. Shared component: `MessageAttachments.tsx`.

### 12. Support tickets
`/admin/dashboard/support` (+ `[id]` TicketWorkspace). List with search + filter by assigned admin + status (open/in_progress/resolved/closed). Detail = full thread + compose + attachment upload + assignment dropdown + status buttons. `unread_message_count` + `last_message_at` drive the nav badge (BKE 00152). Most teams pipe inbound from Postmark/Intercom into `tickets` + `conversation_messages`.

### 13. Sales CRM inbox
`/admin/dashboard/inbox` (+ `[id]` LeadWorkspace). Inbound sales leads routed to `sales@`/`enterprise@`. Tabs: ACTIVE / NEW / WON / LOST / ALL. Status pipeline `new → qualified → demo_booked → proposal → won/lost` with colored badges + `estimated_value_cents`. Same thread/compose/assign UI as tickets.

### 14. Personal team mailboxes
`/admin/dashboard/mail` (+ `[id]` MailWorkspace). Two-way free-form email on personal admin addresses (e.g. `moe@`, `david@`). Views: INBOX / UNREAD / STARRED / ARCHIVED / SPAM / TRASH, label filter, `ComposeLauncher`. Unread floats to top. **Shared read state** (`mail_threads.read_at`) means both super-admins see one read mark (BKE 00139); `last_inbound_at` (00134) — not `last_activity_at` — drives unread so your own replies don't mark a thread unread.

---

## 15. Email automation console (Automations)

`/admin/dashboard/email` mounts the drip engine's admin UI. The engine itself (templates, campaigns, enrollments, send worker, layout) is owned by **[EMAIL-INFRASTRUCTURE-PLAYBOOK](EMAIL-INFRASTRUCTURE-PLAYBOOK.md)** — don't re-spec it here. Admin surface:
- **Campaigns** — list + create (name + `trigger_event`); step builder (`delay_hours`, `template_slug`, `skip_if`, `exit_goal`); status draft/active/paused/archived.
- **Templates** — CRUD (subject, html/text body, from), slug-referenced, soft-delete via `archived_at`, duplicate action.
- **Enrollments** — active enrollments per campaign (user, current step, next send) + unenroll/pause.
- **Analytics** — opens/clicks/unsubscribes/bounce per campaign.
- **Layout** — global header/footer/signature for Postmark sends.

Trigger events seen in BKE: `trial_signup`, `paid_signup`, `inactive_7d`, `trial_ending_3d`, `lead_signup`, `abandoned_checkout`, `manual`.

---

## 16. Analytics tab

`/admin/dashboard/analytics` mounts the analytics dashboard surface — range engine (TODAY/7D/30D/QUARTER/YEAR/ALL/CUSTOM), engaged-only toggle, funnels, channel classifier, **Active-Now globe** (GeoIP lat/lon), per-page drill-down, reports. **Owned by [ADMIN-ANALYTICS-DASHBOARD-PLAYBOOK](../a-tier/ADMIN-ANALYTICS-DASHBOARD-PLAYBOOK.md)** on top of the `page_events` ingest from [CUSTOMER-ANALYTICS-PLAYBOOK](../a-tier/CUSTOMER-ANALYTICS-PLAYBOOK.md). The admin console just routes to it. Server-side `unstable_cache` (~30s) keyed on range + filters keeps it cheap.

---

## 17. SEO & GEO tab

`/admin/dashboard/seo-geo` mounts the SEO/GEO engine console — trend discovery (`trend_candidates`), keyword/grade tracking (`trend_pages`), collection rollups, country analytics, GSC sync status (`seo_sync_runs`). **Owned by [SEO-GEO-ENGINE-PLAYBOOK](../a-tier/SEO-GEO-ENGINE-PLAYBOOK.md).** Only ship this tab for apps with a content/SEO surface.

---

## 18. Settings

`/admin/dashboard/settings` — tabbed:
1. **Profile** — admin name, display name, title, signature HTML.
2. **Revenue** — `admin_revenue` ledger, filter by kind + timeframe, sortable. (super_admin)
3. **Expenses** — `admin_expenses` ledger by vendor + manual add/delete. (super_admin)
4. **API Keys** — enterprise key per-slot CRUD + beta-mode toggle + "Test key" (§20).
5. **Compliance** — matrix OUTPUT_TYPES × PHASE → blocks; PUT invalidates worker cache. Owned by [AI-COMPLIANCE-GATE-PLAYBOOK](../c-tier/AI-COMPLIANCE-GATE-PLAYBOOK.md). (`CompliancePanel`)
6. **Credits** — per-format pricing table (provider cost, calls, infra, multiplier) + caps; live `credits = max(1, ceil(((provider*calls)+infra)*mult/unit))` preview. Owned by [CREDIT-METERING-PLAYBOOK](CREDIT-METERING-PLAYBOOK.md). (`CreditAllocationPanel`)
7. **Onboarding** — onboarding-step video URL config. (`OnboardingVideosPanel`)

Reference: `settings/page.tsx` + `_compliance/`, `_credit-allocation/`, `_onboarding/` panels.

---

## 19. Impersonation flow

**Goal:** admin clicks "Login As" on a customer, browser session swaps to that user, banner appears on every page, "Exit" returns to admin. No password ever leaves the customer's profile.

1. `POST /api/admin/impersonate/start { targetUserId }` — `requireAdmin()` gate.
2. Service-role `auth.admin.generateLink({ type: 'magiclink', email: target.email })` mints a one-shot link.
3. INSERT `admin_impersonations` with `started_at` + `expires_at = now() + 1h`.
4. Set a signed side-channel cookie `app-admin-session = { adminUserId, impersonationId, ts }` — HMAC-signed with `IMPERSONATION_SECRET`. **Never store the admin's full sb-* cookies inline** — chunked auth cookies overflow the 4KB Set-Cookie limit and get silently dropped. Side cookie carries IDs only.
5. Server-side: exchange the magic link to set the target's session cookies (overwriting the admin's), redirect to `/dashboard`.
6. `<ImpersonationBanner/>` reads a public flag from `/api/me` (`impersonatedBy: 'admin@…'`) and renders a sticky banner.
7. **Auto-expire:** banner JS sets a 1h timeout → `/api/admin/impersonate/stop`. Server also enforces `started_at + 1h < now()` on every `/api/admin/*` guard.
8. `POST /api/admin/impersonate/stop` — read side cookie, verify HMAC, mint a fresh magic link for `adminUserId`, restore admin session, UPDATE `admin_impersonations.ended_at`.

Reference: `src/lib/admin-impersonation.ts`.

---

## 20. Enterprise API key bridge

Admin-configured keys override user keys when `betaMode=false`; during beta users BYO. Resolution lives in two places:
- **Server:** `getEnterpriseKeysServer()` in `admin-keys-server.ts` — service-role read of `enterprise_api_keys` + `enterprise_api_config`. Used by every render worker + `/api/generate*`.
- **Client:** `getEffectiveApiKeys()` in `admin.ts` — reads a cached snapshot hydrated on dashboard load.

Rules: `betaMode=true` → user's own keys; `betaMode=false` → enterprise keys filtered to `status='valid'` + non-empty. **Founding-member bypass:** grandfathered BYO tiers ALWAYS use their own keys regardless of `betaMode` — load-bearing, encode explicitly. Settings → API Keys provides per-slot CRUD + "Test key" that calls the provider and flips `status`.

---

## 21. Refund issuance

Two refund kinds (tie into the credit-refund infra's per-`run_id` idempotency):
- **Credit refund** — `refund_credits(workspace, run_id, amount, note)` RPC restores credits + writes a `credit_refunds` row. Idempotency key = `admin:${adminId}:${customerId}:${ts}`.
- **Money refund** — Stripe `refunds.create({ charge })`; record `metadata.stripe_refund_id` in the `admin_actions` row.

UI: customer detail → "Issue refund" → modal with `[credit | money]` toggle, amount, required reason, source-charge dropdown → `POST /api/admin/customers/[id]/action { action:'refund' }` → audited.

---

## 22. Customer health metrics

Lazy aggregation on customer-detail GET (materialize if traffic warrants): last sign-in (`auth.users.last_sign_in_at`), last activity (`MAX(<primary-action>.created_at)`), 30d activity count, 30d credit burn (`SUM(credit_transactions.amount) WHERE amount<0`), trial state, MRR contribution. Surface as a card row; red-flag thresholds (no login >14d, 0 activity 7d on a paid plan) flip the row to a warning style.

---

## 23. Realtime notifications (the "cha-ching")

`<AdminNotifications/>` (mounted in the dashboard layout) opens Supabase Realtime subscriptions to `tickets`, `leads`, and `mail_threads`. On a new inbound row it fires a toast + a **cash-register chime** (`cha-ching.mp3`), and maintains the nav badge counts (unassigned/unread tickets, unclaimed leads). Two gotchas BKE hit:
- **Autoplay unlock** — browsers block audio until a user gesture; gate the first `.play()` behind a one-time click/visibility unlock.
- **Closure stability** — long-lived subscriptions capture stale state; route handlers through `useRef` callback refs, not closed-over values.

Reference: `_components/AdminNotifications.tsx`.

---

## 24. Admin Web Push

`<AdminPushSetup/>` registers a service worker (`/sw.js`) and subscribes the admin to Web Push so signups/tickets notify even when the tab is closed. Endpoints: `POST /api/admin/push/{subscribe,unsubscribe,test}`, keyed to `push_subscriptions`. **iOS only delivers push to a Home-Screen-installed PWA** — detect `display-mode: standalone` and prompt to "Add to Home Screen" first. Server sends via `web-push` with VAPID keys.

Reference: `_components/AdminPushSetup.tsx`.

---

## 25. Message attachments

All three conversation faces share `MessageAttachments` + `GET /api/admin/attachments/[id]?mode=view|download`. The route is `requireAdmin()`-gated and returns a short-lived **signed** Storage URL — never a public/provider URL (those expire; persist to your own bucket first, §27). Renders inline for images/PDF/text, download for others, with KB/MB size formatting.

---

## 26. Admin → Client sync rule (MANDATORY)

**Every piece of client state an admin must view, edit, or report on bridges through `updateCustomer()` → `syncCustomerToClient()`.** Hard rule. When adding new client state, ask "does the admin console need to know about this?" — if yes, wire the sync before the feature is done. Surfaces that must stay synced: plan/tier, credits/usage, account status, profile identity, add-ons/entitlements, enterprise key overrides, pipeline/usage stats, connected sources. In a pure-Supabase app the sync collapses to shared DB reads — the contract doesn't change.

---

## 27. Common pitfalls

**❌ Don't enforce admin-only client-side.** A `role==='admin'` UI gate is a hint, never a boundary. Server-side `requireAdmin()` is the source of truth.

**❌ Don't skip the audit log on destructive actions.** Downgrades, suspensions, refunds, deletes, admin-toggles — all call `recordAdminAction()` after the real write. Best-effort (`try/catch/console.warn`) so logging can't roll back the action, but every mutating path must attempt it.

**❌ Don't let impersonation persist.** Hard 1h cap server-side (re-validated every `/api/admin/*` call), client auto-expire, explicit "Exit" UI.

**❌ Don't expose the service-role key in the admin UI.** Browser code NEVER imports `SUPABASE_SERVICE_ROLE_KEY`. Every privileged read/write proxies through `/api/admin/*`.

**❌ Don't put `auth.uid()` lookups in admin RLS without caching.** `using (exists (select 1 from profiles where id=auth.uid() and role='admin'))` is an N+1 per row. Mint the flag into JWT claims (`(auth.jwt()->>'role')='admin'`) or use a SECURITY DEFINER helper. (Same applies to `has_mailbox_access` — keep it SECURITY DEFINER + stable.)

**❌ Don't build destructive sync routines.** Admin actions that rebuild a JSONB blob from scratch wipe fields the admin didn't touch. Sync is merge-only; only overwrite a key when the new value is non-empty. (BKE 2026-04-23/04-28.)

**❌ Don't store provider URLs from admin actions.** Refund receipts, charge URLs, attachments — anything third-party expires. Persist to your own Storage, store the path. (BKE 2026-04-24.)

**❌ Don't drive mail "unread" off `last_activity_at`.** Your own outbound reply bumps activity → thread looks unread forever. Use `last_inbound_at`. Use a SHARED `read_at` so multiple admins see one read state. (BKE 00134/00139.)

**❌ Don't forget audio autoplay + closure-stability** in realtime notifications (§23).

**❌ Don't tighten Zod schemas on admin recovery fields.** If a workspace/customer ID is `.optional()` on a recovery path, keep it optional — hard validation turns missing-data into a 400, the very condition recovery exists for.

---

## 28. Kickoff prompt

> Execute the Admin Console Playbook at `<workspace>/templates/s-tier/ADMIN-CONSOLE-PLAYBOOK.md` for this app. Mirror the BKE implementation — when in doubt about a pattern, read the BKE file rather than re-deriving. Reference repo: `<reference-app>/`.
>
> Inputs:
> - Tenant model: [profiles.role / workspaces.is_admin]
> - Auth playbook shipped (tenant + admin flag + account_status exist): [yes / no]
> - Billing provider: [Stripe live / Stripe deferred — stub P&L / none — credit-only]
> - In scope this pass (check the §2 nav): [overview / accounts / signups / cold-leads / users / support / sales-inbox / mail / email-automations / analytics / seo-geo / settings / impersonation / audit / enterprise-keys / refunds / realtime-notifications / web-push]
> - Out of scope: [list]
> - Founding-member bypass for enterprise key resolution: [yes / no]
>
> Pause after migrations + `requireAdmin()` + `recordAdminAction()` ship for review. Then build overview → accounts list/detail → action endpoints. Conversation system (tickets/inbox/mail) as one core. Impersonation last; it touches the most surfaces.

---

**Reference files in BKE:**
- `src/app/admin/` — full admin UI tree
- `dashboard/layout.tsx` — admin shell + role gate + nav + notifications + push
- `dashboard/page.tsx` — P&L overview
- `dashboard/accounts/`, `signups/`, `leads/` — accounts + funnel segmentation
- `dashboard/support/`, `inbox/`, `mail/` — the conversation system
- `dashboard/email/` — email automation console
- `dashboard/settings/` — settings + _compliance / _credit-allocation / _onboarding panels
- `dashboard/_components/` — AdminNotifications, AdminPushSetup, MessageAttachments
- `src/app/api/admin/` — every privileged endpoint
- `src/lib/admin.ts` — `updateCustomer()`, `syncCustomerToClient()`, `getEffectiveApiKeys()`, role/permission helpers, `PLAN_MRR`
- `src/lib/admin-auth.ts` — `requireAdmin()`
- `src/lib/admin-audit.ts` — `recordAdminAction()`
- `src/lib/admin-impersonation.ts` — HMAC side-cookie
- `src/lib/admin-keys-server.ts` — enterprise key resolver
- Migrations: `00046` (audit), `00042` (impersonation), 00065-00070 (email), 00131-00139 (mail), 00150 (signup segmentation), 00152 (ticket unread), 00164 (magnet leads)

---

**Updated:** 2026-06-23 from the BKE / Kompozy admin console (12-section cockpit: overview P&L, accounts, signup/cold-lead segmentation, unified conversation system, email automation, analytics, SEO/GEO, settings, realtime chime + Web Push). **Reference implementation:** BKE / Kompozy (https://kompozy.io).
