From 66e657a6463dd2ff1b244f27ab81b5da84538f4d Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 16 Feb 2026 22:36:53 -0800 Subject: [PATCH] feat(products): extend product status lifecycle + prelaunch config - Add 6 product statuses: draft, pre_launch, beta, active, sunset, disabled - Add PrelaunchConfig with customFields, CAPTCHA, tagline, maxSignups - Add isValidStatusTransition() helper for safe status changes - Update getRequestProductId() to block draft/sunset/disabled - Add getRequestProductIdForPublic() for pre_launch waitlist routes - Add status transition validation to product update route - Add PRE_LAUNCH_SIGNUP_SYSTEM.md design doc with full roadmap --- docs/PRE_LAUNCH_SIGNUP_SYSTEM.md | 515 ++++++++++++++++++ .../src/lib/request-context.ts | 34 +- .../src/modules/products/routes.ts | 26 +- .../src/modules/products/types.ts | 97 +++- 4 files changed, 666 insertions(+), 6 deletions(-) create mode 100644 docs/PRE_LAUNCH_SIGNUP_SYSTEM.md diff --git a/docs/PRE_LAUNCH_SIGNUP_SYSTEM.md b/docs/PRE_LAUNCH_SIGNUP_SYSTEM.md new file mode 100644 index 00000000..b2cd2104 --- /dev/null +++ b/docs/PRE_LAUNCH_SIGNUP_SYSTEM.md @@ -0,0 +1,515 @@ +# 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 + +- [ ] **1.1** Extend `ProductDoc.status` enum from `'active' | 'disabled'` to `'draft' | 'pre_launch' | 'beta' | 'active' | 'sunset' | 'disabled'` + - File: `services/platform-service/src/modules/products/types.ts` +- [ ] **1.2** Update `CreateProductSchema` and `UpdateProductSchema` with new status values + - File: `services/platform-service/src/modules/products/types.ts` +- [ ] **1.3** Add optional `prelaunchConfig` field to `ProductDoc` + - Include: `signupEnabled`, `launchDate`, `customFields[]`, `confirmationMessage`, `redirectUrl`, `maxSignups` +- [ ] **1.4** Update `getRequestProductId()` status gating logic + - File: `services/platform-service/src/lib/request-context.ts` + - Current code (line 59): only blocks `status === 'disabled'` + - Must block: `draft`, `sunset`, `disabled` + - Must allow: `pre_launch` (only for `/public/waitlist/*` routes), `beta` (only for invited/auth'd users), `active` (all routes) + - Consider adding a `getRequestProductIdForPublic()` variant that permits `pre_launch` +- [ ] **1.5** Update product cache to include new fields (prelaunchConfig) + - File: `services/platform-service/src/modules/products/cache.ts` +- [ ] **1.6** Write tests for product status transitions + - File: `services/platform-service/src/modules/products/products.test.ts` +- [ ] **1.7** Add product status transition validation + - Enforce valid transitions: `draft→pre_launch→beta→active→sunset→disabled` + - Prevent skipping steps (e.g. `draft→active`) unless admin explicitly overrides + - Add `validateStatusTransition(current, next)` helper +- [ ] **1.8** Backward compatibility migration for existing products + - Existing products in Cosmos have `status: 'active' | 'disabled'` — these remain valid + - No data migration needed (new statuses are additive) + - Document: existing consumers that check `status === 'active'` still work unchanged + +### Phase 2 — Waitlist Module (Core) + +- [ ] **2.1** Create `src/modules/waitlist/types.ts` + - `WaitlistEntryDoc` interface (including `emailNormalized`, `invitedAt`, `convertedAt`, `ipHash`) + - `JoinWaitlistSchema` (Zod: `email` with `.email()`, name, source, customData, UTM fields, optional captchaToken) + - `CheckStatusSchema` (Zod: email + unsubscribeToken — POST body, not query params) + - `UnsubscribeSchema` (Zod: email + unsubscribeToken — POST body) + - `UpdateWaitlistEntrySchema` (admin) + - `WaitlistQuerySchema` (pagination, filters: status, source, date range, search) + - `BatchInviteSchema` (count, strategy: `'fifo' | 'priority' | 'random'`) +- [ ] **2.2** Create `src/modules/waitlist/repository.ts` + - `create()` — insert entry, auto-assign position, normalize email (lowercase + trim) + - `getByEmail()` — lookup by `emailNormalized` + productId (dedupe check) + - `getById()` — single entry + - `getByUnsubscribeToken()` — lookup by token (for unsubscribe + status check) + - `list()` — paginated list with filters (status, source, date range) — cross-partition query + - `update()` — update entry fields + - `remove()` — delete entry + - `count()` — total entries per product (cross-partition) + - `getNextPosition()` — position counter (see §0.1 D3 for atomicity options) + - `getByStatus()` — batch query by status (for batch invite) + - `unsubscribe()` — mark as unsubscribed via token + - `resequence()` — recalculate position numbers (admin utility) +- [ ] **2.3** Create `src/modules/waitlist/routes.ts` + - Public: `POST /public/waitlist/:productId` (join — validate CAPTCHA if enabled, validate customData against schema, dedupe by emailNormalized, hash IP) + - Public: `POST /public/waitlist/:productId/status` (check position — requires email + unsubscribeToken in body) + - Public: `GET /public/waitlist/:productId/count` (social proof) + - Public: `GET /public/waitlist/:productId/config` (return prelaunchConfig: customFields, tagline, launchDate — needed by frontend) + - Public: `POST /public/waitlist/unsubscribe` (token in POST body) + - Admin: `GET /api/waitlist` (list — requires `role === 'admin'`) + - Admin: `GET /api/waitlist/stats` (analytics) + - Admin: `GET /api/waitlist/:id` (detail) + - Admin: `PUT /api/waitlist/:id` (update) + - Admin: `DELETE /api/waitlist/:id` (delete + audit log) + - Admin: `POST /api/waitlist/invite` (batch invite — idempotent, skips already-invited) + - Admin: `POST /api/waitlist/export` (CSV + audit log) + - Admin: `POST /api/waitlist/resequence` (re-calculate positions) +- [ ] **2.4** Register waitlist container in `src/lib/cosmos-init.ts` + - Container: `waitlist`, partition key: `/email` + - **Also audit and add all missing containers** currently absent from `CONTAINER_DEFS`: + `invitation_codes`, `referrals`, `subscriptions`, `payments`, `licenses`, `plans`, + `usage_daily`, `api_tokens`, `tracker_items`, `comments`, `votes`, `themes` + (these work today via on-demand `getContainer()` but aren't auto-initialized) +- [ ] **2.5** Register routes in `src/server.ts` + - Public waitlist routes under `/api` prefix + - Admin waitlist routes under `/api` prefix +- [ ] **2.6** Write unit tests + - File: `src/modules/waitlist/waitlist.test.ts` + - Test all CRUD operations, deduplication (case-insensitive email), position assignment, rate limiting + - Test customData validation against customFields schema (reject unknown keys) + - Test idempotent signup (same email returns existing entry) +- [ ] **2.7** Add admin role guard to all admin endpoints + - Reusable `requireAdmin(req)` helper that throws `ForbiddenError` if `role !== 'admin'` + - Apply to all `/api/waitlist/*` admin routes +- [ ] **2.8** Add `waitlist.joined` webhook event + - Add `dispatchWaitlistJoined()` to `src/lib/webhooks.ts` + - Add env var `WEBHOOK_WAITLIST_JOINED_URL` + - Fire async on successful signup (non-blocking) +- [ ] **2.9** Add public product config endpoint + - `GET /public/waitlist/:productId/config` — returns `prelaunchConfig` (customFields, tagline, launchDate, logoUrl) + - Strips sensitive fields (maxSignups, captcha provider details) + - Required for frontend to render the dynamic signup form + +### 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. + +- [ ] **3.1** Add `ref` query param support to `POST /public/waitlist/:productId` + - Lookup referrer by waitlist entry ID + - Set `referredBy` on new entry, set `source: 'referral'` + - Bump referrer's `priority` score by configurable amount + - Guard: referrer must exist and be `status === 'pending'` (prevent gaming) +- [ ] **3.2** Add referral stats to waitlist stats endpoint + - Track: signups via referral, top referrers (by count), referral chain depth +- [ ] **3.3** Generate shareable referral link per waitlist entry + - Format: `https:///waitlist?ref=` + - Return in join response so user can share immediately + - Include in confirmation email (if email sending is wired) +- [ ] **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 + +- [ ] **4.1** Implement `POST /api/waitlist/invite` admin endpoint + - Input: `{ count: number, strategy: 'fifo' | 'priority' | 'random' }` + - Selects top N entries where `status === 'pending'` only (idempotent — skips already invited) + - Auto-generates invitation codes via `invitations/` module (one code per entry) + - Updates waitlist entries: `status → 'invited'`, sets `invitationCodeId` + `invitedAt` + - Returns: `{ invited: number, skipped: number, errors: number }` + - Creates audit log entry via `audit/` module +- [ ] **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. diff --git a/services/platform-service/src/lib/request-context.ts b/services/platform-service/src/lib/request-context.ts index 8cad4ed7..cdf66542 100644 --- a/services/platform-service/src/lib/request-context.ts +++ b/services/platform-service/src/lib/request-context.ts @@ -56,7 +56,39 @@ export function getRequestProductId(req: FastifyRequest): string { // Validate against product registry if (!isValidProduct(id)) throw new BadRequestError(`Unknown product: ${id}`); const product = getProduct(id)!; - if (product.status === 'disabled') throw new BadRequestError(`Product ${id} is disabled`); + + // Block products that are not operational + const blockedStatuses = ['draft', 'sunset', 'disabled'] as const; + if ((blockedStatuses as readonly string[]).includes(product.status)) { + throw new BadRequestError(`Product ${id} is not available (status: ${product.status})`); + } + + return id; +} + +/** + * Extract productId with relaxed status gating — permits `pre_launch` status. + * Used by public waitlist routes where the product isn't fully operational yet. + * Blocks only: `draft`, `disabled`. + */ +export function getRequestProductIdForPublic(req: FastifyRequest): string { + // Reuse extraction logic without calling getRequestProductId (which blocks pre_launch) + let id = req.jwtPayload?.productId; + if (!id) { + const header = req.headers['x-product-id']; + if (typeof header === 'string' && header.length > 0) id = header; + } + if (!id) { + const envFallback = process.env.PRODUCT_ID; + if (envFallback) id = envFallback; + } + if (!id) throw new BadRequestError('productId is required (via JWT or X-Product-Id header)'); + if (!isValidProduct(id)) throw new BadRequestError(`Unknown product: ${id}`); + + const product = getProduct(id)!; + if (product.status === 'draft' || product.status === 'disabled') { + throw new BadRequestError(`Product ${id} is not available (status: ${product.status})`); + } return id; } diff --git a/services/platform-service/src/modules/products/routes.ts b/services/platform-service/src/modules/products/routes.ts index 56f90aff..8922e7fb 100644 --- a/services/platform-service/src/modules/products/routes.ts +++ b/services/platform-service/src/modules/products/routes.ts @@ -11,7 +11,13 @@ import type { FastifyInstance } from 'fastify'; import { BadRequestError, ConflictError, NotFoundError } from '../../lib/errors.js'; import * as repo from './repository.js'; import { loadProductCache, getAllProducts, getProduct } from './cache.js'; -import { CreateProductSchema, UpdateProductSchema, type ProductDoc } from './types.js'; +import { + CreateProductSchema, + UpdateProductSchema, + isValidStatusTransition, + type ProductDoc, + type ProductStatus, +} from './types.js'; export async function productRoutes(app: FastifyInstance) { // List all products (served from cache) @@ -64,12 +70,28 @@ export async function productRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } + // Validate status transition if status is changing + if (parsed.data.status && parsed.data.status !== existing.status) { + if (!isValidStatusTransition(existing.status, parsed.data.status as ProductStatus)) { + throw new BadRequestError( + `Invalid status transition: ${existing.status} → ${parsed.data.status}` + ); + } + } + // Merge deviceLimits if partial update provided - const { deviceLimits, ...rest } = parsed.data; + const { deviceLimits, prelaunchConfig, ...rest } = parsed.data; const updates: Partial = { ...rest }; if (deviceLimits) { updates.deviceLimits = { ...existing.deviceLimits, ...deviceLimits }; } + // Merge prelaunchConfig if partial update provided + if (prelaunchConfig) { + updates.prelaunchConfig = { + ...existing.prelaunchConfig, + ...prelaunchConfig, + } as ProductDoc['prelaunchConfig']; + } const updated = await repo.update(id, updates); if (!updated) throw new NotFoundError('Product update failed'); diff --git a/services/platform-service/src/modules/products/types.ts b/services/platform-service/src/modules/products/types.ts index 74031ba6..25d0c5ca 100644 --- a/services/platform-service/src/modules/products/types.ts +++ b/services/platform-service/src/modules/products/types.ts @@ -1,10 +1,98 @@ /** * Products registry types — central product configuration. * Admin creates products, dev teams use the productId across their stack. + * + * Status lifecycle: draft → pre_launch → beta → active → sunset → disabled */ import { z } from 'zod'; +// ── Status lifecycle ── + +export const PRODUCT_STATUSES = [ + 'draft', + 'pre_launch', + 'beta', + 'active', + 'sunset', + 'disabled', +] as const; + +export type ProductStatus = (typeof PRODUCT_STATUSES)[number]; + +/** Valid forward transitions. Admin override can skip steps. */ +const STATUS_TRANSITIONS: Record = { + draft: ['pre_launch', 'disabled'], + pre_launch: ['beta', 'active', 'disabled'], + beta: ['active', 'disabled'], + active: ['sunset', 'disabled'], + sunset: ['disabled'], + disabled: ['draft'], +}; + +/** + * Check whether a status transition is valid. + * Returns true if transition is allowed, false otherwise. + */ +export function isValidStatusTransition(current: ProductStatus, next: ProductStatus): boolean { + if (current === next) return true; // no-op is always valid + return STATUS_TRANSITIONS[current]?.includes(next) ?? false; +} + +// ── Pre-launch config ── + +export interface CustomField { + key: string; + label: string; + type: 'text' | 'email' | 'select' | 'multiselect' | 'textarea' | 'number' | 'checkbox'; + options?: string[]; + required: boolean; + placeholder?: string; + maxLength?: number; +} + +export interface PrelaunchConfig { + signupEnabled: boolean; + launchDate?: string; + tagline?: string; + logoUrl?: string; + customFields: CustomField[]; + confirmationMessage?: string; + redirectUrl?: string; + maxSignups?: number; + captchaEnabled: boolean; + captchaProvider?: 'turnstile' | 'hcaptcha' | 'recaptcha'; +} + +const CustomFieldSchema = z.object({ + key: z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9_]+$/), + label: z.string().min(1).max(256), + type: z.enum(['text', 'email', 'select', 'multiselect', 'textarea', 'number', 'checkbox']), + options: z.array(z.string().max(256)).max(50).optional(), + required: z.boolean().default(false), + placeholder: z.string().max(256).optional(), + maxLength: z.number().int().min(1).max(5000).optional(), +}); + +export const PrelaunchConfigSchema = z.object({ + signupEnabled: z.boolean().default(false), + launchDate: z.string().optional(), + tagline: z.string().max(512).optional(), + logoUrl: z.string().url().or(z.literal('')).optional(), + customFields: z.array(CustomFieldSchema).max(20).default([]), + confirmationMessage: z.string().max(1024).optional(), + redirectUrl: z.string().url().or(z.literal('')).optional(), + maxSignups: z.number().int().min(1).optional(), + captchaEnabled: z.boolean().default(false), + captchaProvider: z.enum(['turnstile', 'hcaptcha', 'recaptcha']).optional(), +}); + +// ── Product document ── + export interface ProductDoc { id: string; productId: string; @@ -19,7 +107,8 @@ export interface ProductDoc { enterprise: number; }; websiteUrl: string; - status: 'active' | 'disabled'; + status: ProductStatus; + prelaunchConfig?: PrelaunchConfig; createdAt: string; updatedAt: string; } @@ -47,7 +136,8 @@ export const CreateProductSchema = z.object({ trialDays: z.number().int().min(0).max(365).default(14), deviceLimits: DeviceLimitsSchema.default({ free: 1, pro: 3, enterprise: 10 }), websiteUrl: z.string().url().or(z.literal('')).default(''), - status: z.enum(['active', 'disabled']).default('active'), + status: z.enum(PRODUCT_STATUSES).default('active'), + prelaunchConfig: PrelaunchConfigSchema.optional(), }); export const UpdateProductSchema = z.object({ @@ -63,7 +153,8 @@ export const UpdateProductSchema = z.object({ trialDays: z.number().int().min(0).max(365).optional(), deviceLimits: DeviceLimitsSchema.partial().optional(), websiteUrl: z.string().url().or(z.literal('')).optional(), - status: z.enum(['active', 'disabled']).optional(), + status: z.enum(PRODUCT_STATUSES).optional(), + prelaunchConfig: PrelaunchConfigSchema.partial().optional(), }); export type CreateProductInput = z.infer;