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
This commit is contained in:
saravanakumardb1 2026-02-16 22:36:53 -08:00
parent 209213b50d
commit 66e657a646
4 changed files with 666 additions and 6 deletions

View File

@ -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<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
- [ ] **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://<product-url>/waitlist?ref=<entryId>`
- 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.

View File

@ -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;
}

View File

@ -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<ProductDoc> = { ...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');

View File

@ -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<ProductStatus, ProductStatus[]> = {
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<typeof CreateProductSchema>;