<!--
Beta Tester Management 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.
-->

# Beta Tester Management Playbook

**Portable spec for shipping a beta-tester program for an early-stage SaaS: in-app feedback capture, beta-tier gating, founder dashboard, NPS prompts, lifecycle email cohorts, and clean graduation when features ship GA.**

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

---

## 0. Use this spec

**Inputs:**
- Existing `profiles` table with a `role` column (admin role check pattern)
- Supabase Storage bucket (`generated-media` or equivalent) for screenshot uploads
- Email infrastructure with trigger-event hooks (see EMAIL-INFRASTRUCTURE-PLAYBOOK)
- Founding-tier policy decision: is BYO-API-key tied to beta or sold separately?

**Outputs:**
- `beta_notes`, `beta_note_replies`, `beta_nps_scores` tables with RLS
- `profiles.is_beta` flag (orthogonal to plan/tier)
- Floating in-app feedback widget on every authenticated page
- `/dashboard/beta-notes` user-facing feedback board
- `/admin/beta` founder dashboard (list, reply, resolve, NPS rollup)
- `<BetaGate>` component for feature gating
- Beta-graduation SQL flow + 30-day notice email
- Cohort enrollment in lifecycle drips (`trigger_event='beta_onboarding'`)

---

## 1. Architecture

```
src/
├── components/
│   ├── BetaFeedbackWidget.tsx        # Floating button, modal composer
│   ├── BetaGate.tsx                  # <BetaGate>{children}</BetaGate>
│   └── NpsPrompt.tsx                 # Inline survey card
│
├── lib/
│   ├── repo/
│   │   ├── beta-notes.ts             # CRUD + replies
│   │   └── beta-nps.ts               # NPS submit + cooldown check
│   └── admin.ts                      # isAdminUser() + isBetaUser() helpers
│
├── app/
│   ├── dashboard/
│   │   ├── beta-notes/page.tsx       # User-visible feedback board
│   │   └── layout.tsx                # Mounts <BetaFeedbackWidget /> globally
│   ├── admin/
│   │   └── beta/page.tsx             # Founder dashboard
│   └── api/
│       ├── beta-notes/
│       │   ├── upload/route.ts       # Screenshot upload → Storage
│       │   └── notify/route.ts       # Reply notification dispatch
│       └── beta-nps/route.ts         # Single-call NPS submit
│
└── supabase/migrations/
    ├── NNNN_beta_notes.sql
    ├── NNNN_beta_note_replies.sql
    ├── NNNN_beta_nps_scores.sql
    └── NNNN_profiles_is_beta.sql
```

---

## 2. Schema (Supabase)

```sql
-- profiles.is_beta — orthogonal to plan tier
alter table profiles add column if not exists is_beta boolean not null default false;
create index if not exists profiles_is_beta_idx on profiles(is_beta) where is_beta = true;

-- beta_notes: user-submitted feedback
create table beta_notes (
  id              uuid primary key default gen_random_uuid(),
  user_id         uuid not null references auth.users(id) on delete cascade,
  author_name     text not null default '',
  message         text not null,
  route           text,                  -- pathname at time of capture
  severity        text not null default 'wish'
                  check (severity in ('concern','issue','wish')),
  screenshot_url  text,                  -- Storage path, NOT signed URL
  viewport        jsonb,                 -- { width, height }
  browser         text,                  -- user-agent string
  status          text not null default 'open'
                  check (status in ('open','in_progress','shipped','wont_do')),
  resolved_at     timestamptz,
  created_at      timestamptz not null default now(),
  updated_at      timestamptz not null default now()
);

create index beta_notes_user_idx on beta_notes(user_id);
create index beta_notes_severity_idx on beta_notes(severity);
create index beta_notes_status_idx on beta_notes(status);
create index beta_notes_created_idx on beta_notes(created_at desc);

-- beta_note_replies: admin + author threaded responses
create table beta_note_replies (
  id          uuid primary key default gen_random_uuid(),
  note_id     uuid not null references beta_notes(id) on delete cascade,
  user_id     uuid not null references auth.users(id) on delete cascade,
  author_name text not null default '',
  body        text not null,
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now()
);

-- beta_nps_scores: appended log, not last-write-wins
create table beta_nps_scores (
  id          uuid primary key default gen_random_uuid(),
  user_id     uuid not null references auth.users(id) on delete cascade,
  score       int not null check (score between 0 and 10),
  comment     text,
  prompted_at timestamptz not null default now(),
  created_at  timestamptz not null default now()
);
create index beta_nps_user_idx on beta_nps_scores(user_id, created_at desc);
```

---

## 3. RLS — mirror the same shape across all three tables

Reference: `supabase/migrations/00021_beta_notes.sql` and `00044_beta_note_replies.sql`.

```sql
create or replace function is_beta_user() returns boolean
language sql stable security definer as $$
  select exists (select 1 from profiles p where p.id = auth.uid() and p.is_beta = true);
$$;

create or replace function is_admin_user() returns boolean
language sql stable security definer as $$
  select exists (select 1 from profiles p where p.id = auth.uid() and p.role = 'admin');
$$;

alter table beta_notes enable row level security;
create policy beta_notes_read on beta_notes for select
  using (is_beta_user() or is_admin_user());
create policy beta_notes_insert on beta_notes for insert
  with check (is_beta_user() and user_id = auth.uid());
create policy beta_notes_update on beta_notes for update
  using (user_id = auth.uid() or is_admin_user())
  with check (user_id = auth.uid() or is_admin_user());
create policy beta_notes_delete on beta_notes for delete
  using (user_id = auth.uid() or is_admin_user());
```

Replies mirror the exact policy shape; NPS scores are insert-own + admin-read only (no update / delete).

---

## 4. In-app feedback widget

Reference: `src/app/dashboard/beta-notes/page.tsx` is the full board; the floating widget is a thinner cousin.

**Required behavior:**
- Floating button bottom-right of every authenticated page; mounted once in `dashboard/layout.tsx`.
- Click → non-modal slide-over panel (NOT a full-screen modal). User keeps the offending screen visible while typing.
- Captures automatically: `route = window.location.pathname`, `viewport = { width: innerWidth, height: innerHeight }`, `browser = navigator.userAgent`.
- Optional screenshot via the page's `html2canvas` (or browser `getDisplayMedia`); uploads through `/api/beta-notes/upload` to a private Storage bucket; `screenshot_url` stores the bucket path, NEVER a signed URL (re-sign on read).
- Severity picker: `concern` / `issue` / `wish` — three pills, default `wish`.
- Submit inserts the row client-side via the Supabase browser client (RLS gates `is_beta_user()`).

**Visibility gate:** widget renders only when `profile.is_beta === true`. Non-beta users get a "Request beta access" link in Settings instead.

---

## 5. Founder-side admin dashboard

Route: `/admin/beta` (admin-gated via the same proxy rule as the rest of `/admin/*`).

**Required surfaces:**
- Open-note list, sorted by `severity DESC, created_at DESC` (concerns first, then issues, then wishes).
- Filters: severity, status, route, user.
- Inline reply UI: textarea + send → INSERT into `beta_note_replies` with `user_id = auth.uid()` (admin's own id).
- Status select (`open` / `in_progress` / `shipped` / `wont_do`); flipping to `shipped` or `wont_do` writes `resolved_at = now()`.
- NPS rollup: rolling 30-day average, distribution chart, list of recent comments. Computed client-side from `beta_nps_scores` query.
- Screenshot thumbnails: re-sign `screenshot_url` on render (Storage paths are private).

---

## 6. Reply notifications

When an admin posts a reply:
1. Trigger writes the row (RLS allows admin insert).
2. Edge function or DB trigger fires `/api/beta-notes/notify` which:
   - Inserts an in-app notification row (user sees a badge on `/dashboard/beta-notes`).
   - Enqueues a transactional email via the lifecycle email infra (`trigger_event = 'beta_reply'`).
3. The user's beta panel shows the threaded reply inline under the originating note.

Do NOT route admin replies to the user's email synchronously from the dashboard click — use the queued email path so a reply doesn't fail the admin write on email-provider hiccups.

---

## 7. Beta-only feature gating

`<BetaGate>` component pattern:

```tsx
// src/components/BetaGate.tsx
export function BetaGate({ children, fallback = null }: Props) {
  const { profile, loading } = useProfile();
  if (loading) return null;
  if (!profile?.is_beta) return fallback;
  return <>{children}</>;
}
```

**Defense in depth:** every server route that backs a beta feature does its own `is_beta_user()` check via `requireUser()` + profile read. Client-only gating is decorative — RLS + route check is the real gate. The pattern matches BKE's `src/lib/admin.ts` admin enforcement.

---

## 8. Beta graduation flow

When a feature ships GA:

1. **Notice email — 30 days out.** Enroll the cohort in a one-shot `trigger_event='beta_graduation_notice'` campaign that explains: feature is going GA, pricing change (if any), what happens to their data. Cross-ref EMAIL-INFRASTRUCTURE-PLAYBOOK §3 (cohort enrollment).
2. **Grace window.** Beta cohort retains access for 30 days regardless of new pricing.
3. **Cohort SQL — graduation day.** Single migration removes the gate at the feature level, NOT at the user level:
   ```sql
   -- Don't flip is_beta off — preserve for tracking. Remove the feature-side gate.
   update feature_flags set requires_beta = false where flag_key = 'new_thing';
   ```
4. **Preserve `is_beta` forever.** Used for cohort analytics, founding-member loyalty perks, and future invitations. Flipping it off loses the history.

---

## 9. Lifecycle email cohort tagging

Beta cohort gets its own drip stream alongside the standard trial / paid campaigns. Configure in your email infra (see EMAIL-INFRASTRUCTURE-PLAYBOOK §2 for the trigger-event schema):

| trigger_event | When | Purpose |
|---|---|---|
| `beta_onboarding` | `is_beta` flipped to true | Welcome, how to file notes, link to beta call cadence |
| `beta_weekly_checkin` | 7 days after onboard, weekly | "Anything you tried this week? Reply with one win + one friction." |
| `beta_feedback_prompt` | 14 days in, then every 21 days | "Open the feedback widget — what's missing?" |
| `beta_nps_invite` | 14 days in, then every 30 days | Triggers in-app NPS card on next login |
| `beta_reply` | Admin replies to a note | Notify user there's a response |
| `beta_graduation_notice` | 30 days before GA | Heads up, pricing, what changes |

Enrollment uses the same DB trigger pattern as trial-signup (auto-enroll when `is_beta` becomes true).

---

## 10. NPS prompts

Schema in §2. Behavior:

- **First prompt:** 14 days after `is_beta = true` (NOT first week — users haven't formed opinions).
- **Recurring:** every 30 days thereafter; cooldown gated by `select max(created_at) from beta_nps_scores where user_id = auth.uid()`.
- **UI:** single-call card embedded inline in dashboard, NOT a blocking modal. 0–10 buttons + optional one-line comment. Dismissible — dismissal counts as a skip, not a score.
- **Storage:** insert-only via `/api/beta-nps`. Each prompt is a new row — never overwrite. Trend over time is the signal.

---

## 11. Founding-tier overlay (optional)

If you run a founding-member tier (e.g. BKE Kompozy's <founding-price>/mo BYO lifetime — see `bke_founding_tier.md`):

- Founding membership is orthogonal to `is_beta`. A user can be founding + beta, founding + not-beta, or beta-only.
- BYO API keys are gated on `profiles.is_founding`, NOT `is_beta`. Load-bearing invariant in any `getEffectiveApiKeys()` resolver: a founding member keeps BYO regardless of whether the beta toggle moves.
- Signup window for founding tier is a hard date cutoff stored as a feature-flag row, not a per-user field. After the cutoff, no new `is_founding = true` writes.

---

## 12. Common pitfalls

**❌ Don't make the feedback widget block-the-page.** Floating button, non-modal slide-over. A blocking modal trains users to dismiss rather than report — the bug they wanted to flag is still on the screen behind it.

**❌ Don't auto-route beta_notes to admin email.** Admin reads them in the dashboard. Even at 50 beta users, "ping me every time someone files a wish" will kill the founder's inbox in a week and notes will start getting ignored at the email layer.

**❌ Don't use beta_notes for incident escalation.** Beta notes are product-feedback velocity. Production incidents (data loss, billing failure, account lockout) need a separate `support_tickets` table with paging, SLA, and on-call routing. Don't conflate.

**❌ Don't graduate the beta tier silently.** Email cohort first, 30 days minimum. Flipping the gate without warning generates support tickets and looks like a regression to the user.

**❌ Don't tie beta access to subscription plan.** `is_beta` is orthogonal to free/paid. A paying user can be beta; a free trial user can be beta; a churned user can lose beta. Coupling them means one of the two flags becomes meaningless.

**❌ Don't NPS-prompt within the first week.** Accuracy is terrible — users haven't done enough to form an opinion. Earliest viable prompt is day 14.

**❌ Don't store provider screenshot URLs.** Upload to your own Storage bucket; persist the bucket path; re-sign on read. Provider URLs (and `getDisplayMedia` blob URLs) expire — cross-ref the BKE rule "NEVER store provider URLs."

**❌ Don't rebuild `is_beta` from a sync routine.** Bulk-replace patterns have wiped user state on BKE twice. Beta access is an explicit grant — only mutate from a named admin action.

---

## 13. Kickoff prompt

> Execute the Beta Tester Management Playbook at `<workspace>/templates/a-tier/BETA-TESTER-PLAYBOOK.md` for this new app. Mirror the BKE implementation. Reference codebase: `<reference-app>/src/`. Read `beta_notes migration` and `beta_note_replies migration` before writing migrations.
>
> Inputs:
> - Existing profiles + role column: [yes / no — if no, run AUTH-ONBOARDING-PLAYBOOK first]
> - Storage bucket for screenshots: [bucket name]
> - Email infra wired: [yes / no — if no, run EMAIL-INFRASTRUCTURE-PLAYBOOK first]
> - Founding tier: [yes — $X/mo cutoff DATE / no]
>
> Pause after the three migrations + `<BetaGate>` + floating widget ship for review.

---

**Reference files in BKE:**
- `supabase/migrations/00021_beta_notes.sql` — table + RLS + upvote trigger
- `supabase/migrations/00044_beta_note_replies.sql` — replies mirror beta_notes RLS shape
- `supabase/migrations/00023_beta_notes_admin_status.sql` — admin status-change policy
- `supabase/migrations/00027_beta_zoom_recordings.sql` — beta-call recordings sidecar
- `src/app/dashboard/beta-notes/page.tsx` — full board UI with filters, replies, upvotes
- `src/lib/admin.ts` — `is_admin_user()` + effective-api-keys resolver
- `bke_founding_tier.md` — founding-tier invariants
