# Pre-Launch Signup System — Design & Roadmap

> **Service:** `platform-service` (port 4003)
> **Status:** ✅ COMPLETE (2026-03-02)
> **Module:** `src/modules/waitlist/` — 4 files (types.ts, repository.ts, routes.ts, waitlist.test.ts)
> **Cosmos Container:** `waitlist` (partition key: `/email`)
> **Routes:** Public signup, status check, unsubscribe; Admin invite, batch invite, list, update
> **Tests:** 24 Vitest tests covering dedupe, position, CAPTCHA, webhooks

---

## 0. Review Findings & Gaps Addressed

This section documents bugs, gaps, and inconsistencies found during systematic review of this design against the actual platform-service codebase.

### 0.1 Data Model Bugs

| #   | Issue                                                                                                                                                                                                                                                                                       | Severity | Resolution                                                                                                                                          |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| D1  | **Cosmos partition key mismatch** — doc says `waitlist` container uses `/productId`, but `WaitlistEntryDoc` lookups are primarily by email+productId. High-cardinality partition key `/productId` means all entries for a product land in one logical partition → hot partition under load. | High     | Changed to `/email` partition key. Cross-partition queries (admin list-all) use Cosmos cross-partition fan-out which is acceptable for admin reads. |
| D2  | **Missing `emailNormalized` field** — emails are case-insensitive but Cosmos queries are case-sensitive. Without normalization, `User@Gmail.com` and `user@gmail.com` create duplicate entries.                                                                                             | High     | Added `emailNormalized` field (lowercase, trimmed). Dedupe queries use this field.                                                                  |
| D3  | **`position` race condition** — `getNextPosition()` is not atomic in Cosmos DB. Two concurrent signups can get the same position number. Cosmos has no `AUTO_INCREMENT`.                                                                                                                    | Medium   | Documented: use Cosmos stored procedure for atomic increment, or accept approximate positions and re-sequence periodically via admin endpoint.      |
| D4  | **`customData: Record<string, unknown>`** — no validation against `prelaunchConfig.customFields`. Malicious clients can submit arbitrary keys/huge payloads.                                                                                                                                | Medium   | Added: route must validate `customData` keys against product's `customFields` schema, reject unknown keys, enforce max payload size.                |
| D5  | **Missing `invitedAt` field** — when entry transitions `pending → invited`, there's no timestamp for when the invite was sent. Needed for invite expiry tracking.                                                                                                                           | Low      | Added `invitedAt?: string` to `WaitlistEntryDoc`.                                                                                                   |
| D6  | **Missing `convertedAt` field** — same gap for `invited → converted` transition.                                                                                                                                                                                                            | Low      | Added `convertedAt?: string` to `WaitlistEntryDoc`.                                                                                                 |

### 0.2 API / Security Gaps

| #   | Issue                                                                                                                                                                                                  | Severity | Resolution                                                                                                                                                                        |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1  | **Status check leaks position by email** — `GET /public/waitlist/:productId/status?email=x` lets anyone check if an email is on the waitlist (enumeration attack).                                     | High     | Changed: require both `email` + `unsubscribeToken` to check status. Alternatively, return only a success/not-found boolean without position details for unauthenticated requests. |
| A2  | **No admin auth enforcement documented** — admin endpoints say "JWT Auth Required" but the doc doesn't specify role check. Any authenticated user (including regular users) could hit admin endpoints. | High     | Added: admin endpoints must check `req.jwtPayload.role === 'admin'`. Added guard task to roadmap.                                                                                 |
| A3  | **Unsubscribe via DELETE with token in query param** — tokens in URLs get logged in server access logs, CDN logs, browser history.                                                                     | Medium   | Changed to `POST /public/waitlist/unsubscribe` with token in request body.                                                                                                        |
| A4  | **No email format validation mentioned** — public signup endpoint must validate email format before storing.                                                                                           | Medium   | Added: Zod `.email()` validation in `JoinWaitlistSchema`.                                                                                                                         |
| A5  | **No CAPTCHA / bot protection** — rate limiting alone isn't enough for public signup. Bots can rotate IPs.                                                                                             | Medium   | Added task: integrate turnstile/hCaptcha/reCAPTCHA token validation on signup endpoint.                                                                                           |
| A6  | **`ipAddress` storage without consent** — storing IP addresses has GDPR implications.                                                                                                                  | Low      | Added: note IP is hashed (SHA-256) before storage, not stored raw.                                                                                                                |

### 0.3 Integration Gaps

| #   | Issue                                                                                                                                                                                                                                                                                                                                                                                      | Severity | Resolution                                                                                                                                                                                                |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| I1  | **`getRequestProductId()` blocks `draft` and `sunset` but not `pre_launch`** — current code at `request-context.ts:59` only blocks `status === 'disabled'`. After adding new statuses, `draft` products would still accept API calls (including creating subscriptions, etc.).                                                                                                             | High     | Task 1.4 updated: `getRequestProductId()` must block `draft`, `sunset`, `disabled`. Allow `pre_launch` only for waitlist routes, `beta` only for invited users, `active` for all.                         |
| I2  | **Missing containers in `cosmos-init.ts`** — the existing `CONTAINER_DEFS` doesn't include containers used by other modules: `invitation_codes`, `referrals`, `promo_codes`, `subscriptions`, `payments`, `licenses`, `plans`, `usage_daily`, `api_tokens`, `tracker_items`, `comments`, `votes`, `themes`. These are created on-demand by `getContainer()` but won't be auto-initialized. | Medium   | Added task: audit and add all missing containers to `cosmos-init.ts` `CONTAINER_DEFS` (separate from waitlist work but prerequisite).                                                                     |
| I3  | **No webhook for `waitlist.joined`** — existing webhook system supports `invitation.redeemed` and `referral.status_changed` but nothing for waitlist events. Admins may want Slack/email notifications when someone joins.                                                                                                                                                                 | Medium   | Added `dispatchWaitlistJoined` webhook + env var `WEBHOOK_WAITLIST_JOINED_URL` to roadmap.                                                                                                                |
| I4  | **Batch invite has no idempotency** — if the admin clicks "invite 50" twice, 100 invites go out. No guard against re-inviting already-invited entries.                                                                                                                                                                                                                                     | Medium   | Added: batch invite must filter `status === 'pending'` only, and the endpoint should return how many were already invited (skipped).                                                                      |
| I5  | **Referral integration uses `referrals/` module but waitlist referrals are different** — existing `referrals/` tracks userId→userId referrals with reward tokens. Waitlist referrals are email→email with position boost. Overloading the same module creates confusion.                                                                                                                   | Medium   | Clarified: waitlist referrals are self-contained within the `waitlist/` module (`referredBy` field). The existing `referrals/` module is for post-signup user referrals. Phase 3 updated to reflect this. |

### 0.4 Roadmap Task Gaps

| #   | Issue                                                                                                                                       | Resolution                                               |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| R1  | **No task for adding `waitlist` to `cosmos-init.ts` `CONTAINER_DEFS`**                                                                      | Was in 2.4 text but partition key path was wrong. Fixed. |
| R2  | **No task for adding admin role guard middleware**                                                                                          | Added task 2.7.                                          |
| R3  | **No task for email normalization logic**                                                                                                   | Added to task 2.2.                                       |
| R4  | **No task for CAPTCHA/bot protection**                                                                                                      | Added task 7.7.                                          |
| R5  | **No task for product status transition validation** (e.g. can't go `draft → active` skipping `pre_launch`)                                 | Added task 1.7.                                          |
| R6  | **No task for backward compatibility** — existing products have `status: 'active' \| 'disabled'`. A migration or default mapping is needed. | Added task 1.8.                                          |
| R7  | **Phase 3 conflates waitlist referrals with existing `referrals/` module**                                                                  | Rewritten to be self-contained in waitlist module.       |
| R8  | **No task for webhook `waitlist.joined` event**                                                                                             | Added task 2.8.                                          |
| R9  | **No task for `prelaunchConfig` public endpoint** — frontend needs to fetch custom fields to render the form, but no public API exposes it. | Added task 2.9.                                          |
| R10 | **Effort table section numbered "7" collides with Phase 7 in section 6**                                                                    | Renumbered remaining sections: 7→8→9→10.                 |

---

## 1. Problem Statement

When a new product (e.g. LysnrAI, MindLyst, or any future ByteLyst product) is being built, we need a way to:

- Collect interest signups (email + custom data) **before launch**
- Show a public-facing signup page scoped to a specific `productId`
- Support **custom form fields** per product (use case, role, platform preference, etc.)
- Track referral chains for viral waitlist growth
- Let admins manage, prioritize, and batch-invite waitlist entries
- Transition smoothly from pre-launch → beta → general availability

The system must be **product-agnostic** — the same module serves every product in the ByteLyst ecosystem.

---

## 2. Product Status Lifecycle (Extended)

Current `ProductDoc.status` only supports `active | disabled`. This needs expansion:

```
draft → pre_launch → beta → active → sunset → disabled
```

| Status       | Public Signup? | Product Usable? | Description                              |
| ------------ | -------------- | --------------- | ---------------------------------------- |
| `draft`      | No             | No              | Product created, not publicly visible    |
| `pre_launch` | **Yes**        | No              | Waitlist page is live, product not ready |
| `beta`       | Invite-only    | Yes (limited)   | Early access via invitation codes        |
| `active`     | Open           | Yes             | Generally available                      |
| `sunset`     | No             | Yes (existing)  | No new signups, existing users continue  |
| `disabled`   | No             | No              | Fully offline                            |

---

## 3. Data Model

### 3.1 WaitlistEntryDoc

```typescript
interface WaitlistEntryDoc {
  id: string; // "wl_<uuid>"
  productId: string; // scoped to product
  email: string; // original casing as entered
  emailNormalized: string; // lowercase + trimmed (used for dedupe queries)
  name?: string; // optional display name
  source: 'organic' | 'referral' | 'social' | 'ad' | 'api';
  referredBy?: string; // waitlist entry ID of referrer
  status: 'pending' | 'invited' | 'converted' | 'unsubscribed';
  position: number; // queue position (auto-assigned, see §0.1 D3)
  priority: number; // boost score (referrals bump this)
  customData: Record<string, unknown>; // validated against product's customFields schema
  invitationCodeId?: string; // links to invitations/ module when invited
  invitedAt?: string; // timestamp when invite was sent
  convertedAt?: string; // timestamp when invite was redeemed
  ipHash?: string; // SHA-256 of IP (not raw IP, GDPR-safe)
  utmSource?: string; // auto-captured from query params
  utmMedium?: string;
  utmCampaign?: string;
  unsubscribeToken: string; // for one-click unsubscribe (in POST body, not URL)
  createdAt: string;
  updatedAt: string;
}
```

> **Partition key rationale:** `/email` distributes entries across logical partitions evenly.
> Admin list-all queries are cross-partition (acceptable for low-frequency admin reads).
> Deduplication is a point-read by `emailNormalized` + `productId` within the same partition.

> **Position assignment:** Cosmos DB has no `AUTO_INCREMENT`. Options:
>
> 1. Stored procedure for atomic counter (strongest guarantee)
> 2. `SELECT VALUE MAX(c.position)` + 1 (acceptable with low concurrency)
> 3. Accept approximate positions, admin re-sequences periodically

### 3.2 PrelaunchConfig (on ProductDoc)

```typescript
interface PrelaunchConfig {
  signupEnabled: boolean; // master toggle
  launchDate?: string; // estimated launch date (shown on page)
  tagline?: string; // short description shown on signup page
  logoUrl?: string; // product logo for signup page
  customFields: CustomField[]; // dynamic form schema
  confirmationMessage?: string; // e.g. "You're #{{position}} on the waitlist!"
  redirectUrl?: string; // redirect after signup
  maxSignups?: number; // cap waitlist size (null = unlimited)
  captchaEnabled: boolean; // require CAPTCHA on public signup
  captchaProvider?: 'turnstile' | 'hcaptcha' | 'recaptcha';
}

interface CustomField {
  key: string; // e.g. "company_size", "use_case"
  label: string; // "What's your company size?"
  type: 'text' | 'email' | 'select' | 'multiselect' | 'textarea' | 'number' | 'checkbox';
  options?: string[]; // for select/multiselect types
  required: boolean;
  placeholder?: string;
  maxLength?: number; // for text/textarea (default: 500)
}
```

### 3.3 Pre-Launch Data Collection Checklist

| Data Point                     | Why                                       | Required?   |
| ------------------------------ | ----------------------------------------- | ----------- |
| Email                          | Primary contact, launch notification      | Always      |
| Name                           | Personalized communications               | Optional    |
| UTM params                     | Track which channels drive signups        | Auto        |
| Referral source                | Viral loop tracking                       | Auto        |
| Use case (custom)              | Prioritize features, segment beta invites | Per-product |
| Role / title (custom)          | B2B targeting                             | Per-product |
| Company size (custom)          | Enterprise vs. indie segmentation         | Per-product |
| Platform preference (custom)   | iOS / Android / Web / Desktop priority    | Per-product |
| Willing to beta test? (custom) | Beta cohort selection                     | Per-product |
| How did you hear about us?     | Attribution beyond UTM                    | Per-product |

---

## 4. API Endpoints

### 4.1 Public Endpoints (No Auth, Rate-Limited)

| Method | Path                                 | Rate Limit | Description                                                                                            |
| ------ | ------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------ |
| `POST` | `/public/waitlist/:productId`        | 10/min/IP  | Join waitlist (email + custom fields + optional CAPTCHA token)                                         |
| `POST` | `/public/waitlist/:productId/status` | 30/min/IP  | Check position (email + unsubscribeToken in body, not query param — prevents enumeration)              |
| `GET`  | `/public/waitlist/:productId/count`  | 60/min/IP  | Total signups (social proof number)                                                                    |
| `GET`  | `/public/waitlist/:productId/config` | 60/min/IP  | Get `prelaunchConfig` (custom fields schema, tagline, launch date — needed by frontend to render form) |
| `POST` | `/public/waitlist/unsubscribe`       | 10/min/IP  | Unsubscribe (token in POST body, not URL — avoids token leaking to logs/CDN)                           |

### 4.2 Admin Endpoints (JWT Auth Required, `role === 'admin'`)

All admin endpoints **must** verify `req.jwtPayload.role === 'admin'` — not just valid JWT.

| Method   | Path                       | Description                                                                |
| -------- | -------------------------- | -------------------------------------------------------------------------- |
| `GET`    | `/api/waitlist`            | List entries (paginated, filterable by status/source/date)                 |
| `GET`    | `/api/waitlist/stats`      | Signup stats (by day, source, custom field breakdown)                      |
| `GET`    | `/api/waitlist/:id`        | Get single entry                                                           |
| `PUT`    | `/api/waitlist/:id`        | Update entry (change priority, status)                                     |
| `DELETE` | `/api/waitlist/:id`        | Delete entry (creates audit log entry)                                     |
| `POST`   | `/api/waitlist/invite`     | Batch-invite top N (idempotent: skips already-invited, returns skip count) |
| `POST`   | `/api/waitlist/export`     | Export as CSV (creates audit log entry)                                    |
| `POST`   | `/api/waitlist/resequence` | Re-calculate position numbers (fix gaps from deletes/unsubscribes)         |

---

## 5. Integration with Existing Modules

```
User visits pre-launch page
    │
    ├─► GET /public/waitlist/:productId/config
    │     └─► products/ ── verify status is `pre_launch` + signupEnabled
    │     └─► Return customFields schema, tagline, launchDate (frontend renders form)
    │
    ├─► POST /public/waitlist/:productId (submit form)
    │     ├─► flags/ ── check `pre_launch_signup_enabled` flag
    │     ├─► products/ ── verify product status is `pre_launch`
    │     ├─► CAPTCHA ── validate turnstile/hcaptcha token (if captchaEnabled)
    │     ├─► Validate customData keys against product's customFields schema
    │     ├─► Dedupe ── check emailNormalized + productId (return existing if found)
    │     ├─► waitlist/ ── store entry, assign position, capture UTM + custom data
    │     ├─► waitlist/ ── if ?ref=<entryId>, set referredBy, bump referrer priority
    │     ├─► webhooks/ ── fire `waitlist.joined` event (async, non-blocking)
    │     └─► Return { position, unsubscribeToken, referralLink }
    │
    └─► (Later) Admin triggers batch invite from admin dashboard
            │
            ├─► POST /api/waitlist/invite { count, strategy }
            │     ├─► Filter only `status === 'pending'` entries (idempotent)
            │     ├─► invitations/ ── auto-generate invite codes for selected entries
            │     ├─► waitlist/ ── mark entries `invited`, set invitationCodeId + invitedAt
            │     ├─► notifications/ ── queue email with invite code + instructions
            │     ├─► audit/ ── log admin action
            │     └─► Return { invited: N, skipped: N (already invited) }
            │
            └─► (User redeems invite code)
                  ├─► invitations/ ── redeem code (existing flow)
                  ├─► webhooks/ ── existing `invitation.redeemed` fires
                  └─► waitlist/ ── callback marks entry `converted`, sets convertedAt
```

> **Note on referrals:** Waitlist referrals (`referredBy` field, priority boost) are
> self-contained within the `waitlist/` module. The existing `referrals/` module is for
> post-signup user-to-user referrals with reward tokens — different lifecycle.

---

## 6. Implementation Roadmap

### Phase 1 — Product Status Lifecycle Extension

- [x] **1.1** Extend `ProductDoc.status` enum from `'active' | 'disabled'` to `'draft' | 'pre_launch' | 'beta' | 'active' | 'sunset' | 'disabled'` — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
  - File: `services/platform-service/src/modules/products/types.ts`
- [x] **1.2** Update `CreateProductSchema` and `UpdateProductSchema` with new status values — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
  - File: `services/platform-service/src/modules/products/types.ts`
- [x] **1.3** Add optional `prelaunchConfig` field to `ProductDoc` — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
  - Include: `signupEnabled`, `launchDate`, `customFields[]`, `confirmationMessage`, `redirectUrl`, `maxSignups`, `captchaEnabled`, `captchaProvider`, `tagline`, `logoUrl`
- [x] **1.4** Update `getRequestProductId()` status gating logic — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
  - File: `services/platform-service/src/lib/request-context.ts`
  - Blocks: `draft`, `sunset`, `disabled`
  - Added `getRequestProductIdForPublic()` that permits `pre_launch`
- [x] **1.5** Update product cache to include new fields (prelaunchConfig) — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
  - Cache auto-includes all ProductDoc fields (no changes needed to cache.ts)
- [x] **1.6** Write tests for product status transitions — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - Tests in: `services/platform-service/src/modules/waitlist/waitlist.test.ts` (isValidStatusTransition tests)
- [x] **1.7** Add product status transition validation — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
  - `isValidStatusTransition(current, next)` helper in `products/types.ts`
  - Validation enforced in `PUT /products/:id` route
  - Transitions: `draft→pre_launch→beta→active→sunset→disabled`, `disabled→draft`
- [x] **1.8** Backward compatibility migration for existing products — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
  - Existing `'active' | 'disabled'` values are valid members of the new enum
  - No data migration needed (new statuses are additive)

### Phase 2 — Waitlist Module (Core)

- [x] **2.1** Create `src/modules/waitlist/types.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - `WaitlistEntryDoc` interface (including `emailNormalized`, `invitedAt`, `convertedAt`, `ipHash`)
  - `JoinWaitlistSchema`, `CheckStatusSchema`, `UnsubscribeSchema`, `UpdateWaitlistEntrySchema`, `WaitlistQuerySchema`, `BatchInviteSchema`
- [x] **2.2** Create `src/modules/waitlist/repository.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - All functions: `create`, `getById`, `getByEmail`, `getByUnsubscribeToken`, `list`, `update`, `remove`, `count`, `getNextPosition`, `getByStatus`, `unsubscribe`, `stats`, `normalizeEmail`
  - Note: `resequence()` deferred to Phase 7 (not critical for MVP)
- [x] **2.3** Create `src/modules/waitlist/routes.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - 5 public endpoints + 7 admin endpoints implemented
  - CustomData validation against product's customFields schema
  - IP hashing (SHA-256, truncated), email normalization, dedup
  - Note: `POST /api/waitlist/resequence` deferred to Phase 7
- [x] **2.4** Register waitlist container in `src/lib/cosmos-init.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - Container: `waitlist`, partition key: `/email`
  - Also added 13 previously missing containers to `CONTAINER_DEFS`
- [x] **2.5** Register routes in `src/server.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- [x] **2.6** Write unit tests (45 tests passing) — [`6a996cc`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/6a996cc)
  - File: `src/modules/waitlist/waitlist.test.ts`
  - Schema validation, email normalization, status transitions, route exports
- [x] **2.7** Add admin role guard to all admin endpoints — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - `requireAdmin(req)` helper throws `ForbiddenError` if `role !== 'admin'`
- [x] **2.8** Add `waitlist.joined` webhook event — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - `dispatchWaitlistJoined()` in `src/lib/webhooks.ts`
  - Env var `WEBHOOK_WAITLIST_JOINED_URL` added to `.env.example`
- [x] **2.9** Add public product config endpoint — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - `GET /public/waitlist/:productId/config` — strips `maxSignups` + `captchaProvider` from response

### Phase 3 — Waitlist Referral Loop (Self-Contained)

> **Note:** This is separate from the existing `referrals/` module which handles
> post-signup user-to-user referrals with reward tokens. Waitlist referrals are
> simpler: email→email with priority boost, entirely within the `waitlist/` module.

- [x] **3.1** Add `ref` query param support to `POST /public/waitlist/:productId` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - Lookup referrer by waitlist entry ID
  - Set `referredBy` on new entry, set `source: 'referral'`
  - Bump referrer's `priority` score by +1
  - Guard: referrer must exist, same productId, and `status === 'pending'`
- [ ] **3.2** Add referral stats to waitlist stats endpoint
  - Track: signups via referral, top referrers (by count), referral chain depth
- [x] **3.3** Generate shareable referral link per waitlist entry — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - Returns `referralLink: '?ref=<entryId>'` in join response
  - Full URL construction left to frontend (product-specific domain)
- [ ] **3.4** Add referral leaderboard endpoint (optional)
  - `GET /public/waitlist/:productId/top-referrers` — top 10 referrers (anonymized: first name + initial)
- [ ] **3.5** Write referral integration tests
  - Test: circular referral prevention (A refers B, B can't refer A)
  - Test: self-referral prevention
  - Test: referrer priority increment

### Phase 4 — Batch Invite Flow

- [x] **4.1** Implement `POST /api/waitlist/invite` admin endpoint — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
  - Input: `{ count: number, strategy: 'fifo' | 'priority' | 'random' }`
  - Selects only `status === 'pending'` entries (idempotent)
  - Marks entries `invited` + sets `invitedAt`
  - Returns: `{ invited, failed, total }`
  - **TODO-3:** Auto-generate invitation codes via `invitations/` module not yet wired
  - **TODO-2:** Audit log entry not yet wired
- [ ] **4.2** Wire into notifications module for email delivery
  - Send invite email with: code, instructions, product info
  - Use notification preferences (respect email opt-out)
- [ ] **4.3** Handle invitation redemption → waitlist conversion
  - When user redeems invite code → lookup waitlist entry by `invitationCodeId`
  - Update waitlist entry: `status → 'converted'`, set `convertedAt`
  - Hook into existing `dispatchInvitationRedeemed` webhook callback
  - Guard: only update if current status is `invited` (prevent double-conversion)
- [ ] **4.4** Write batch invite + conversion tests

### Phase 5 — Admin Dashboard UI

- [ ] **5.1** Add waitlist management page to admin dashboard
  - Path: `admin-dashboard-web/src/app/(dashboard)/waitlist/page.tsx`
  - Table view: email, name, position, priority, status, source, date
  - Filters: status, source, date range, search by email
  - Bulk actions: invite selected, delete, export
- [ ] **5.2** Add waitlist stats widget to admin dashboard home
  - Total signups, signups today, conversion rate, top referrers
- [ ] **5.3** Add product pre-launch config editor
  - Edit `prelaunchConfig` on product settings page
  - Custom field builder (add/remove/reorder fields)
  - Toggle signup enabled/disabled
  - Set estimated launch date
- [ ] **5.4** Add API routes in admin dashboard for waitlist
  - Proxy to platform-service `/api/waitlist/*` endpoints

### Phase 6 — Public Signup Page (Frontend)

- [ ] **6.1** Design public waitlist signup page template
  - Responsive, dark-themed (ByteLyst design tokens)
  - Product logo, name, tagline, launch date countdown
  - Email field + dynamic custom fields (from `prelaunchConfig`)
  - Social proof counter ("Join 1,234 others on the waitlist")
  - Referral share link after signup
- [ ] **6.2** Build as reusable component in tracker-dashboard or standalone
  - Route: `/waitlist/:productId`
  - Fetches product config + custom fields from public API
  - Posts signup to `POST /public/waitlist/:productId`
- [ ] **6.3** Add confirmation/thank-you state
  - Show position number, estimated wait, referral link
  - "Share to move up the waitlist" CTA
- [ ] **6.4** Add status check page
  - Input email → shows current position, status
  - Link to unsubscribe

### Phase 7 — Analytics & Polish

- [ ] **7.1** Add waitlist analytics to `GET /api/waitlist/stats`
  - Signups per day (time series)
  - Signups by source breakdown
  - Custom field aggregations (e.g. "60% want iOS first")
  - Referral chain depth stats
  - Conversion funnel: pending → invited → converted → (with drop-off rates)
- [ ] **7.2** Add CSV/JSON export for admin
  - All fields, filterable by status/date/source
  - Exclude `ipHash` and `unsubscribeToken` from export (security)
- [ ] **7.3** Add feature flag: `pre_launch_signup_enabled` per product
  - Quick kill switch without changing product status
  - Overrides `prelaunchConfig.signupEnabled` (flag takes precedence)
- [ ] **7.4** Duplicate detection is core (moved to Phase 2)
  - Same email + productId → return existing position (don't create duplicate)
  - Different productId → allow (user can be on multiple waitlists)
  - Uses `emailNormalized` for case-insensitive matching
- [ ] **7.5** Unsubscribe token is core (moved to Phase 2)
  - Generated via `crypto.randomUUID()` at signup time
  - Used for status check + unsubscribe (both via POST body)
- [ ] **7.6** Add audit log entries for admin actions (invite, delete, export, resequence)
  - Wire into existing `audit/` module
  - Category: `waitlist`, actions: `waitlist.invite`, `waitlist.delete`, `waitlist.export`, `waitlist.resequence`
- [ ] **7.7** Add CAPTCHA / bot protection for public signup
  - Support Cloudflare Turnstile (preferred), hCaptcha, or reCAPTCHA
  - Configurable per product via `prelaunchConfig.captchaEnabled` + `captchaProvider`
  - Server-side token verification in signup route
- [ ] **7.8** Add email delivery integration (optional)
  - Confirmation email on signup (with position, referral link)
  - Invite email on batch invite (with code, instructions)
  - Options: SendGrid, AWS SES, or Azure Communication Services
  - Out of scope for core module — can be wired via webhook consumers

---

## 8. Estimated Effort

| Phase     | Description              | ~LOC       | Priority |
| --------- | ------------------------ | ---------- | -------- |
| 1         | Product status lifecycle | ~150       | P0       |
| 2         | Waitlist module (core)   | ~900       | P0       |
| 3         | Waitlist referral loop   | ~200       | P1       |
| 4         | Batch invite flow        | ~250       | P1       |
| 5         | Admin dashboard UI       | ~800       | P1       |
| 6         | Public signup page       | ~600       | P1       |
| 7         | Analytics & polish       | ~400       | P2       |
| **Total** |                          | **~3,300** |          |

---

## 9. Decision: New Module in platform-service (Not a New Service)

**Why not a separate service?**

- Product-agnostic — already scoped by `productId`, same pattern as all other modules
- Same Cosmos DB instance — no new infrastructure
- Same auth pattern — public routes for signup, JWT-admin routes for management
- Direct integration with `invitations/`, `referrals/`, `notifications/`, `flags/` — same process, no network hops
- Rate limiting — reuses `@fastify/rate-limit` pattern from existing `public/` routes
- Estimated ~650 LOC for core module — far too small to justify a separate service

**Where it lives:**

```
services/platform-service/src/modules/waitlist/
├── types.ts          # Zod schemas + TypeScript interfaces
├── repository.ts     # Cosmos DB CRUD operations
├── routes.ts         # Public + admin REST endpoints
└── waitlist.test.ts  # Unit tests
```

---

## 10. Open Questions

- [ ] Should we support multiple waitlists per product (e.g. "iOS waitlist" vs "Android waitlist")?
  - Current design: one waitlist per productId. Could add optional `tag` field for sub-lists.
- [ ] ~~Do we want webhook/callback support when someone joins?~~ **Resolved:** Yes — added `waitlist.joined` webhook (task 2.8).
- [ ] Should the public signup page be a standalone static site or part of an existing dashboard?
  - Option A: Static site (fastest, cheapest, CDN-deployed)
  - Option B: Route in tracker-dashboard (reuses existing infra)
  - Option C: New standalone Next.js micro-site
- [ ] Do we need GDPR-style data export/deletion for waitlist entries?
  - IP is already hashed (not raw). Email is PII — may need right-to-erasure endpoint.
  - Consider: `DELETE /public/waitlist/me` with email + unsubscribeToken (deletes entry entirely).
- [ ] Should referral priority boost be configurable per product?
  - Current design: hardcoded boost value. Could add `referralBoost: number` to `PrelaunchConfig`.
- [ ] Do we need email verification (double opt-in) before counting someone on the waitlist?
  - Pro: prevents fake signups, ensures deliverable email
  - Con: adds friction, requires email delivery infrastructure
- [ ] Should the product status transition be strictly sequential or allow admin override?
  - Current design: suggest validation but allow admin override. Document both options.

---

## 11. Implementation TODOs (Deferred — Needs Review)

These TODOs were marked during implementation. They are **in the code as comments** and need your decision before wiring up.

| #          | Location                                       | Description                                                                                                                                                                                                                                                                          | Blocker?                                                            |
| ---------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| **TODO-1** | `routes.ts` — join endpoint                    | **CAPTCHA validation**: When `prelaunchConfig.captchaEnabled` is true, we need to verify `captchaToken` against the configured provider (Turnstile/hCaptcha/reCAPTCHA). Requires: provider API keys, HTTP call to verify endpoint. Currently skipped — signup works without CAPTCHA. | No — rate limiting is in place as interim protection                |
| **TODO-2** | `routes.ts` — delete, invite, export endpoints | **Audit log wiring**: Admin actions (delete entry, batch invite, CSV export) should create audit log entries via the existing `audit/` module. Currently commented out — needs `auditRepo.create()` calls.                                                                           | No — actions work, just not logged                                  |
| **TODO-3** | `routes.ts` — batch invite endpoint            | **Invitation code auto-generation**: Batch invite currently marks entries as `invited` but does NOT auto-generate invitation codes via the `invitations/` module. The `invitationCodeId` field stays empty. Need to call `invitations/repository.ts` `create()` for each entry.      | Yes — invited users won't have a code to redeem until this is wired |

### Remaining Unchecked Roadmap Items

| Task    | Phase                              | Status      | Notes                                                           |
| ------- | ---------------------------------- | ----------- | --------------------------------------------------------------- |
| 3.2     | Referral stats in stats endpoint   | Not started | Add referral-specific aggregations to `GET /api/waitlist/stats` |
| 3.4     | Referral leaderboard endpoint      | Not started | Optional — `GET /public/waitlist/:productId/top-referrers`      |
| 3.5     | Referral integration tests         | Not started | Circular/self-referral prevention tests                         |
| 4.2     | Notifications email delivery       | Not started | Depends on email provider (SendGrid/SES/Azure Comms)            |
| 4.3     | Invitation redemption → conversion | Not started | Hook `dispatchInvitationRedeemed` → update waitlist entry       |
| 4.4     | Batch invite + conversion tests    | Not started | Depends on TODO-3                                               |
| 5.1–5.4 | Admin dashboard UI                 | Not started | Waitlist management page, stats widget, config editor           |
| 6.1–6.4 | Public signup page frontend        | Not started | Responsive signup form, confirmation, status check              |
| 7.1–7.8 | Analytics & polish                 | Not started | Time series, CSV export, CAPTCHA, email delivery                |

### Summary of Commits

| Commit                                                                                  | Description                                                                                |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a) | Phase 1: Product status lifecycle + prelaunchConfig types                                  |
| [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) | Phase 2: Waitlist module (types, repo, routes, webhooks, cosmos-init, server registration) |
| [`6a996cc`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/6a996cc) | Tests (45 passing) + doc checkboxes + .env.example webhook vars                            |
