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:
parent
209213b50d
commit
66e657a646
515
docs/PRE_LAUNCH_SIGNUP_SYSTEM.md
Normal file
515
docs/PRE_LAUNCH_SIGNUP_SYSTEM.md
Normal 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.
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user