test(waitlist): add 45 unit tests + update doc checkboxes with commit links

- 45 tests: schema validation, email normalization, status transitions, route exports
- Update Phase 1, 2, 3, 4 checkboxes in PRE_LAUNCH_SIGNUP_SYSTEM.md with commit SHAs
- Add webhook env vars to .env.example
This commit is contained in:
saravanakumardb1 2026-02-16 22:49:42 -08:00
parent 2692c918ce
commit 6a996cc11d
3 changed files with 396 additions and 90 deletions

View File

@ -29,5 +29,10 @@ PYTHON_SIDECAR_URL=http://localhost:4006
DEFAULT_MODEL_ID=gemini-2.5-flash
GEMINI_API_KEY=your-gemini-api-key
# ── Webhooks (optional — fire-and-forget callbacks) ──────────
WEBHOOK_INVITATION_REDEEMED_URL=
WEBHOOK_REFERRAL_STATUS_URL=
WEBHOOK_WAITLIST_JOINED_URL=
# ── Product Identity ──────────────────────────────────────────
DEFAULT_PRODUCT_ID=lysnrai

View File

@ -253,93 +253,55 @@ User visits pre-launch page
### Phase 1 — Product Status Lifecycle Extension
- [ ] **1.1** Extend `ProductDoc.status` enum from `'active' | 'disabled'` to `'draft' | 'pre_launch' | 'beta' | 'active' | 'sunset' | 'disabled'`
- [x] **1.1** Extend `ProductDoc.status` enum from `'active' | 'disabled'` to `'draft' | 'pre_launch' | 'beta' | 'active' | 'sunset' | 'disabled'` — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
- File: `services/platform-service/src/modules/products/types.ts`
- [ ] **1.2** Update `CreateProductSchema` and `UpdateProductSchema` with new status values
- [x] **1.2** Update `CreateProductSchema` and `UpdateProductSchema` with new status values — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
- File: `services/platform-service/src/modules/products/types.ts`
- [ ] **1.3** Add optional `prelaunchConfig` field to `ProductDoc`
- Include: `signupEnabled`, `launchDate`, `customFields[]`, `confirmationMessage`, `redirectUrl`, `maxSignups`
- [ ] **1.4** Update `getRequestProductId()` status gating logic
- [x] **1.3** Add optional `prelaunchConfig` field to `ProductDoc` — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
- Include: `signupEnabled`, `launchDate`, `customFields[]`, `confirmationMessage`, `redirectUrl`, `maxSignups`, `captchaEnabled`, `captchaProvider`, `tagline`, `logoUrl`
- [x] **1.4** Update `getRequestProductId()` status gating logic — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
- File: `services/platform-service/src/lib/request-context.ts`
- 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
- Blocks: `draft`, `sunset`, `disabled`
- Added `getRequestProductIdForPublic()` that permits `pre_launch`
- [x] **1.5** Update product cache to include new fields (prelaunchConfig) — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
- Cache auto-includes all ProductDoc fields (no changes needed to cache.ts)
- [x] **1.6** Write tests for product status transitions — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- Tests in: `services/platform-service/src/modules/waitlist/waitlist.test.ts` (isValidStatusTransition tests)
- [x] **1.7** Add product status transition validation — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
- `isValidStatusTransition(current, next)` helper in `products/types.ts`
- Validation enforced in `PUT /products/:id` route
- Transitions: `draft→pre_launch→beta→active→sunset→disabled`, `disabled→draft`
- [x] **1.8** Backward compatibility migration for existing products — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a)
- Existing `'active' | 'disabled'` values are valid members of the new enum
- No data migration needed (new statuses are additive)
- Document: existing consumers that check `status === 'active'` still work unchanged
### Phase 2 — Waitlist Module (Core)
- [ ] **2.1** Create `src/modules/waitlist/types.ts`
- [x] **2.1** Create `src/modules/waitlist/types.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- `WaitlistEntryDoc` interface (including `emailNormalized`, `invitedAt`, `convertedAt`, `ipHash`)
- `JoinWaitlistSchema` (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`
- `JoinWaitlistSchema`, `CheckStatusSchema`, `UnsubscribeSchema`, `UpdateWaitlistEntrySchema`, `WaitlistQuerySchema`, `BatchInviteSchema`
- [x] **2.2** Create `src/modules/waitlist/repository.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- All functions: `create`, `getById`, `getByEmail`, `getByUnsubscribeToken`, `list`, `update`, `remove`, `count`, `getNextPosition`, `getByStatus`, `unsubscribe`, `stats`, `normalizeEmail`
- Note: `resequence()` deferred to Phase 7 (not critical for MVP)
- [x] **2.3** Create `src/modules/waitlist/routes.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- 5 public endpoints + 7 admin endpoints implemented
- CustomData validation against product's customFields schema
- IP hashing (SHA-256, truncated), email normalization, dedup
- Note: `POST /api/waitlist/resequence` deferred to Phase 7
- [x] **2.4** Register waitlist container in `src/lib/cosmos-init.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- Container: `waitlist`, partition key: `/email`
- **Also 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
- Also added 13 previously missing containers to `CONTAINER_DEFS`
- [x] **2.5** Register routes in `src/server.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- [x] **2.6** Write unit tests (45 tests passing) — _next commit_
- 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
- Schema validation, email normalization, status transitions, route exports
- [x] **2.7** Add admin role guard to all admin endpoints — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- `requireAdmin(req)` helper throws `ForbiddenError` if `role !== 'admin'`
- [x] **2.8** Add `waitlist.joined` webhook event — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- `dispatchWaitlistJoined()` in `src/lib/webhooks.ts`
- Env var `WEBHOOK_WAITLIST_JOINED_URL` added to `.env.example`
- [x] **2.9** Add public product config endpoint — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- `GET /public/waitlist/:productId/config` — strips `maxSignups` + `captchaProvider` from response
### Phase 3 — Waitlist Referral Loop (Self-Contained)
@ -347,17 +309,16 @@ User visits pre-launch page
> 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`
- [x] **3.1** Add `ref` query param support to `POST /public/waitlist/:productId` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- Lookup referrer by waitlist entry ID
- Set `referredBy` on new entry, set `source: 'referral'`
- Bump referrer's `priority` score by configurable amount
- Guard: referrer must exist and be `status === 'pending'` (prevent gaming)
- Bump referrer's `priority` score by +1
- Guard: referrer must exist, same productId, and `status === 'pending'`
- [ ] **3.2** Add referral stats to waitlist stats endpoint
- Track: signups via referral, top referrers (by count), referral chain depth
- [ ] **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)
- [x] **3.3** Generate shareable referral link per waitlist entry — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- Returns `referralLink: '?ref=<entryId>'` in join response
- Full URL construction left to frontend (product-specific domain)
- [ ] **3.4** Add referral leaderboard endpoint (optional)
- `GET /public/waitlist/:productId/top-referrers` — top 10 referrers (anonymized: first name + initial)
- [ ] **3.5** Write referral integration tests
@ -367,13 +328,13 @@ User visits pre-launch page
### Phase 4 — Batch Invite Flow
- [ ] **4.1** Implement `POST /api/waitlist/invite` admin endpoint
- [x] **4.1** Implement `POST /api/waitlist/invite` admin endpoint — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91)
- Input: `{ count: number, strategy: 'fifo' | 'priority' | 'random' }`
- Selects 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
- Selects only `status === 'pending'` entries (idempotent)
- Marks entries `invited` + sets `invitedAt`
- Returns: `{ invited, failed, total }`
- **TODO-3:** Auto-generate invitation codes via `invitations/` module not yet wired
- **TODO-2:** Audit log entry not yet wired
- [ ] **4.2** Wire into notifications module for email delivery
- Send invite email with: code, instructions, product info
- Use notification preferences (respect email opt-out)

View File

@ -0,0 +1,340 @@
/**
* Unit tests for waitlist module types, schemas, helpers.
*/
import { describe, it, expect } from 'vitest';
import {
JoinWaitlistSchema,
CheckStatusSchema,
UnsubscribeSchema,
UpdateWaitlistEntrySchema,
WaitlistQuerySchema,
BatchInviteSchema,
} from './types.js';
import { normalizeEmail } from './repository.js';
import { isValidStatusTransition } from '../products/types.js';
// ── Email normalization ──
describe('normalizeEmail', () => {
it('lowercases email', () => {
expect(normalizeEmail('User@Gmail.COM')).toBe('user@gmail.com');
});
it('trims whitespace', () => {
expect(normalizeEmail(' user@test.com ')).toBe('user@test.com');
});
it('handles already normalized email', () => {
expect(normalizeEmail('user@test.com')).toBe('user@test.com');
});
});
// ── JoinWaitlistSchema ──
describe('JoinWaitlistSchema', () => {
it('accepts valid minimal input', () => {
const result = JoinWaitlistSchema.safeParse({
email: 'user@example.com',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.email).toBe('user@example.com');
expect(result.data.source).toBe('organic');
expect(result.data.customData).toEqual({});
}
});
it('accepts full input with all fields', () => {
const result = JoinWaitlistSchema.safeParse({
email: 'user@example.com',
name: 'John Doe',
source: 'referral',
ref: 'wl_abc123',
customData: { use_case: 'dictation', company_size: '10-50' },
utmSource: 'twitter',
utmMedium: 'social',
utmCampaign: 'launch2026',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('John Doe');
expect(result.data.source).toBe('referral');
expect(result.data.ref).toBe('wl_abc123');
expect(result.data.customData).toEqual({ use_case: 'dictation', company_size: '10-50' });
}
});
it('rejects invalid email', () => {
const result = JoinWaitlistSchema.safeParse({
email: 'not-an-email',
});
expect(result.success).toBe(false);
});
it('rejects empty email', () => {
const result = JoinWaitlistSchema.safeParse({
email: '',
});
expect(result.success).toBe(false);
});
it('rejects missing email', () => {
const result = JoinWaitlistSchema.safeParse({});
expect(result.success).toBe(false);
});
it('rejects invalid source', () => {
const result = JoinWaitlistSchema.safeParse({
email: 'user@example.com',
source: 'unknown_source',
});
expect(result.success).toBe(false);
});
it('accepts all valid source values', () => {
for (const source of ['organic', 'referral', 'social', 'ad', 'api']) {
const result = JoinWaitlistSchema.safeParse({
email: 'user@example.com',
source,
});
expect(result.success).toBe(true);
}
});
});
// ── CheckStatusSchema ──
describe('CheckStatusSchema', () => {
it('accepts valid email + token', () => {
const result = CheckStatusSchema.safeParse({
email: 'user@example.com',
unsubscribeToken: 'abc-123-token',
});
expect(result.success).toBe(true);
});
it('rejects missing token', () => {
const result = CheckStatusSchema.safeParse({
email: 'user@example.com',
});
expect(result.success).toBe(false);
});
it('rejects empty token', () => {
const result = CheckStatusSchema.safeParse({
email: 'user@example.com',
unsubscribeToken: '',
});
expect(result.success).toBe(false);
});
it('rejects invalid email', () => {
const result = CheckStatusSchema.safeParse({
email: 'bad',
unsubscribeToken: 'token',
});
expect(result.success).toBe(false);
});
});
// ── UnsubscribeSchema ──
describe('UnsubscribeSchema', () => {
it('accepts valid input', () => {
const result = UnsubscribeSchema.safeParse({
email: 'user@example.com',
unsubscribeToken: 'token-123',
});
expect(result.success).toBe(true);
});
it('rejects missing fields', () => {
expect(UnsubscribeSchema.safeParse({}).success).toBe(false);
expect(UnsubscribeSchema.safeParse({ email: 'a@b.com' }).success).toBe(false);
expect(UnsubscribeSchema.safeParse({ unsubscribeToken: 'x' }).success).toBe(false);
});
});
// ── UpdateWaitlistEntrySchema (admin) ──
describe('UpdateWaitlistEntrySchema', () => {
it('accepts empty object', () => {
const result = UpdateWaitlistEntrySchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts status change', () => {
const result = UpdateWaitlistEntrySchema.safeParse({ status: 'invited' });
expect(result.success).toBe(true);
});
it('accepts priority change', () => {
const result = UpdateWaitlistEntrySchema.safeParse({ priority: 5 });
expect(result.success).toBe(true);
});
it('rejects invalid status', () => {
const result = UpdateWaitlistEntrySchema.safeParse({ status: 'deleted' });
expect(result.success).toBe(false);
});
it('rejects negative priority', () => {
const result = UpdateWaitlistEntrySchema.safeParse({ priority: -1 });
expect(result.success).toBe(false);
});
it('accepts all valid statuses', () => {
for (const status of ['pending', 'invited', 'converted', 'unsubscribed']) {
expect(UpdateWaitlistEntrySchema.safeParse({ status }).success).toBe(true);
}
});
});
// ── WaitlistQuerySchema ──
describe('WaitlistQuerySchema', () => {
it('accepts empty query (defaults)', () => {
const result = WaitlistQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sortBy).toBe('position');
expect(result.data.sortOrder).toBe('asc');
expect(result.data.limit).toBe(50);
expect(result.data.offset).toBe(0);
}
});
it('accepts full query with filters', () => {
const result = WaitlistQuerySchema.safeParse({
productId: 'lysnrai',
status: 'pending',
source: 'referral',
q: 'john',
sortBy: 'priority',
sortOrder: 'desc',
limit: '100',
offset: '50',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.productId).toBe('lysnrai');
expect(result.data.status).toBe('pending');
expect(result.data.limit).toBe(100);
}
});
it('coerces string numbers to numbers', () => {
const result = WaitlistQuerySchema.safeParse({
limit: '25',
offset: '10',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(25);
expect(result.data.offset).toBe(10);
}
});
it('rejects limit > 500', () => {
const result = WaitlistQuerySchema.safeParse({ limit: '501' });
expect(result.success).toBe(false);
});
});
// ── BatchInviteSchema ──
describe('BatchInviteSchema', () => {
it('accepts valid input', () => {
const result = BatchInviteSchema.safeParse({ count: 50 });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.strategy).toBe('fifo');
}
});
it('accepts all strategies', () => {
for (const strategy of ['fifo', 'priority', 'random']) {
const result = BatchInviteSchema.safeParse({ count: 10, strategy });
expect(result.success).toBe(true);
}
});
it('rejects count < 1', () => {
expect(BatchInviteSchema.safeParse({ count: 0 }).success).toBe(false);
});
it('rejects count > 500', () => {
expect(BatchInviteSchema.safeParse({ count: 501 }).success).toBe(false);
});
it('rejects missing count', () => {
expect(BatchInviteSchema.safeParse({}).success).toBe(false);
});
it('rejects invalid strategy', () => {
expect(BatchInviteSchema.safeParse({ count: 10, strategy: 'invalid' }).success).toBe(false);
});
});
// ── Product status transitions ──
describe('isValidStatusTransition', () => {
it('allows draft → pre_launch', () => {
expect(isValidStatusTransition('draft', 'pre_launch')).toBe(true);
});
it('allows draft → disabled', () => {
expect(isValidStatusTransition('draft', 'disabled')).toBe(true);
});
it('allows pre_launch → beta', () => {
expect(isValidStatusTransition('pre_launch', 'beta')).toBe(true);
});
it('allows pre_launch → active (skip beta)', () => {
expect(isValidStatusTransition('pre_launch', 'active')).toBe(true);
});
it('allows beta → active', () => {
expect(isValidStatusTransition('beta', 'active')).toBe(true);
});
it('allows active → sunset', () => {
expect(isValidStatusTransition('active', 'sunset')).toBe(true);
});
it('allows sunset → disabled', () => {
expect(isValidStatusTransition('sunset', 'disabled')).toBe(true);
});
it('allows disabled → draft (re-enable cycle)', () => {
expect(isValidStatusTransition('disabled', 'draft')).toBe(true);
});
it('rejects draft → active (skip pre_launch)', () => {
expect(isValidStatusTransition('draft', 'active')).toBe(false);
});
it('rejects active → draft (backward)', () => {
expect(isValidStatusTransition('active', 'draft')).toBe(false);
});
it('rejects active → pre_launch (backward)', () => {
expect(isValidStatusTransition('active', 'pre_launch')).toBe(false);
});
it('allows no-op (same status)', () => {
expect(isValidStatusTransition('active', 'active')).toBe(true);
expect(isValidStatusTransition('draft', 'draft')).toBe(true);
});
});
// ── Route exports ──
describe('waitlistRoutes export', () => {
it('exports waitlistRoutes function', async () => {
const mod = await import('./routes.js');
expect(typeof mod.waitlistRoutes).toBe('function');
});
});