# Pre-Launch Signup System — Design & Roadmap > **Service:** `platform-service` (port 4003) > **Module:** `src/modules/waitlist/` > **Cosmos Container:** `waitlist` (partition key: `/email`) > **Status:** Planning --- ## 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`** — 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_" 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; // 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=, 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='` 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 |