From 2692c918cef241c544a84a6b3c2a6bf544710fe9 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 16 Feb 2026 22:45:14 -0800 Subject: [PATCH] feat(waitlist): add pre-launch waitlist module (types, repo, routes) - Create waitlist/types.ts: WaitlistEntryDoc, Zod schemas for join/status/unsubscribe/admin - Create waitlist/repository.ts: full CRUD, dedup by emailNormalized, position assignment, stats - Create waitlist/routes.ts: 5 public endpoints + 7 admin endpoints with role guard - Add waitlist container to cosmos-init.ts (+ 13 previously missing containers) - Add dispatchWaitlistJoined webhook to webhooks.ts - Register waitlistRoutes in server.ts - Public: join, check status, count, config, unsubscribe - Admin: list, stats, get, update, delete, batch invite, CSV export --- .../platform-service/src/lib/cosmos-init.ts | 19 + services/platform-service/src/lib/webhooks.ts | 6 + .../src/modules/waitlist/repository.ts | 258 ++++++++++ .../src/modules/waitlist/routes.ts | 471 ++++++++++++++++++ .../src/modules/waitlist/types.ts | 97 ++++ services/platform-service/src/server.ts | 3 + 6 files changed, 854 insertions(+) create mode 100644 services/platform-service/src/modules/waitlist/repository.ts create mode 100644 services/platform-service/src/modules/waitlist/routes.ts create mode 100644 services/platform-service/src/modules/waitlist/types.ts diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index aad7d158..eecbab2e 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -10,6 +10,25 @@ const CONTAINER_DEFS: Record = { notification_prefs: { partitionKeyPath: '/userId' }, audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 }, feature_flags: { partitionKeyPath: '/id' }, + // Growth modules + invitation_codes: { partitionKeyPath: '/id' }, + referrals: { partitionKeyPath: '/id' }, + // Billing modules + subscriptions: { partitionKeyPath: '/userId' }, + payments: { partitionKeyPath: '/userId' }, + licenses: { partitionKeyPath: '/id' }, + plans: { partitionKeyPath: '/id' }, + usage_daily: { partitionKeyPath: '/userId' }, + // API tokens + api_tokens: { partitionKeyPath: '/id' }, + // Tracker modules + tracker_items: { partitionKeyPath: '/id' }, + comments: { partitionKeyPath: '/itemId' }, + votes: { partitionKeyPath: '/itemId' }, + // Themes + themes: { partitionKeyPath: '/id' }, + // Waitlist (pre-launch signups) + waitlist: { partitionKeyPath: '/email' }, // Mobile capture primitives (MindLyst-style). memory_items: { partitionKeyPath: '/userId' }, daily_briefs: { partitionKeyPath: '/userId' }, diff --git a/services/platform-service/src/lib/webhooks.ts b/services/platform-service/src/lib/webhooks.ts index 683a3014..4359ea85 100644 --- a/services/platform-service/src/lib/webhooks.ts +++ b/services/platform-service/src/lib/webhooks.ts @@ -4,6 +4,7 @@ * Products register webhook URLs via env vars: * WEBHOOK_INVITATION_REDEEMED_URL — called after an invitation code is redeemed * WEBHOOK_REFERRAL_STATUS_URL — called when a referral status transitions + * WEBHOOK_WAITLIST_JOINED_URL — called when someone joins a pre-launch waitlist * * Payloads are JSON; failures are logged but never block the caller. */ @@ -60,3 +61,8 @@ export function dispatchInvitationRedeemed(data: Record): Promi export function dispatchReferralStatusChanged(data: Record): Promise { return dispatchWebhook(process.env.WEBHOOK_REFERRAL_STATUS_URL, 'referral.status_changed', data); } + +/** Dispatch a waitlist.joined webhook. */ +export function dispatchWaitlistJoined(data: Record): Promise { + return dispatchWebhook(process.env.WEBHOOK_WAITLIST_JOINED_URL, 'waitlist.joined', data); +} diff --git a/services/platform-service/src/modules/waitlist/repository.ts b/services/platform-service/src/modules/waitlist/repository.ts new file mode 100644 index 00000000..d3cfef85 --- /dev/null +++ b/services/platform-service/src/modules/waitlist/repository.ts @@ -0,0 +1,258 @@ +/** + * Waitlist repository — Cosmos DB CRUD operations. + * + * Container: `waitlist`, partition key: `/email` + * Cross-partition queries used for admin list/count/stats (acceptable for low-frequency reads). + */ + +import type { SqlParameter } from '@azure/cosmos'; +import { getContainer } from '../../lib/cosmos.js'; +import type { WaitlistEntryDoc, WaitlistStatus, WaitlistSource } from './types.js'; + +const CONTAINER = 'waitlist'; + +function container() { + return getContainer(CONTAINER); +} + +// ── Normalize email for case-insensitive dedup ── + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +// ── Create ── + +export async function create(doc: WaitlistEntryDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as WaitlistEntryDoc; +} + +// ── Read (single) ── + +export async function getById(id: string): Promise { + // id-based lookup requires cross-partition query (partition is /email) + const { resources } = await container() + .items.query({ + query: 'SELECT * FROM c WHERE c.id = @id', + parameters: [{ name: '@id', value: id }], + }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function getByEmail( + emailNormalized: string, + productId: string +): Promise { + const { resources } = await container() + .items.query({ + query: 'SELECT * FROM c WHERE c.emailNormalized = @email AND c.productId = @productId', + parameters: [ + { name: '@email', value: emailNormalized }, + { name: '@productId', value: productId }, + ], + }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function getByUnsubscribeToken(token: string): Promise { + const { resources } = await container() + .items.query({ + query: 'SELECT * FROM c WHERE c.unsubscribeToken = @token', + parameters: [{ name: '@token', value: token }], + }) + .fetchAll(); + return resources[0] ?? null; +} + +// ── List (admin, cross-partition) ── + +export interface ListOptions { + productId?: string; + status?: WaitlistStatus; + source?: WaitlistSource; + q?: string; + sortBy: string; + sortOrder: string; + limit: number; + offset: number; +} + +export async function list(opts: ListOptions): Promise<{ + items: WaitlistEntryDoc[]; + total: number; +}> { + const conditions: string[] = []; + const params: SqlParameter[] = []; + + if (opts.productId) { + conditions.push('c.productId = @productId'); + params.push({ name: '@productId', value: opts.productId }); + } + if (opts.status) { + conditions.push('c.status = @status'); + params.push({ name: '@status', value: opts.status }); + } + if (opts.source) { + conditions.push('c.source = @source'); + params.push({ name: '@source', value: opts.source }); + } + if (opts.q) { + conditions.push('(CONTAINS(LOWER(c.email), @q) OR CONTAINS(LOWER(c.name), @q))'); + params.push({ name: '@q', value: opts.q.toLowerCase() }); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Allowed sort columns (whitelist to prevent injection) + const sortCol = ['position', 'priority', 'createdAt'].includes(opts.sortBy) + ? opts.sortBy + : 'position'; + const sortDir = opts.sortOrder === 'desc' ? 'DESC' : 'ASC'; + + // Count query + const { resources: countRes } = await container() + .items.query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: [...params], + }) + .fetchAll(); + const total = countRes[0] ?? 0; + + // Data query + const dataParams: SqlParameter[] = [ + ...params, + { name: '@offset', value: opts.offset }, + { name: '@limit', value: opts.limit }, + ]; + const { resources: items } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY c.${sortCol} ${sortDir} OFFSET @offset LIMIT @limit`, + parameters: dataParams, + }) + .fetchAll(); + + return { items, total }; +} + +// ── Count ── + +export async function count(productId: string): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status != @excluded', + parameters: [ + { name: '@productId', value: productId }, + { name: '@excluded', value: 'unsubscribed' }, + ], + }) + .fetchAll(); + return resources[0] ?? 0; +} + +// ── Next position ── + +export async function getNextPosition(productId: string): Promise { + const { resources } = await container() + .items.query({ + query: 'SELECT VALUE MAX(c.position) FROM c WHERE c.productId = @productId', + parameters: [{ name: '@productId', value: productId }], + }) + .fetchAll(); + const maxPos = resources[0] ?? 0; + return (typeof maxPos === 'number' ? maxPos : 0) + 1; +} + +// ── Update ── + +export async function update( + id: string, + email: string, + updates: Partial +): Promise { + try { + const { resource: existing } = await container().item(id, email).read(); + if (!existing) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(id, email).replace(merged); + return resource as WaitlistEntryDoc; + } catch { + return null; + } +} + +// ── Unsubscribe ── + +export async function unsubscribe(token: string): Promise { + const entry = await getByUnsubscribeToken(token); + if (!entry) return null; + if (entry.status === 'unsubscribed') return entry; // idempotent + return update(entry.id, entry.email, { status: 'unsubscribed' }); +} + +// ── Delete ── + +export async function remove(id: string, email: string): Promise { + try { + await container().item(id, email).delete(); + return true; + } catch { + return false; + } +} + +// ── Batch query by status (for invite flow) ── + +export async function getByStatus( + productId: string, + status: WaitlistStatus, + sortBy: 'position' | 'priority', + limit: number +): Promise { + const sortCol = sortBy === 'priority' ? 'c.priority DESC' : 'c.position ASC'; + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c WHERE c.productId = @productId AND c.status = @status ORDER BY ${sortCol} OFFSET 0 LIMIT @limit`, + parameters: [ + { name: '@productId', value: productId }, + { name: '@status', value: status }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + return resources; +} + +// ── Stats (admin analytics) ── + +export interface WaitlistStats { + total: number; + byStatus: Record; + bySource: Record; + todaySignups: number; +} + +export async function stats(productId: string): Promise { + const { resources } = await container() + .items.query({ + query: 'SELECT c.status, c.source, c.createdAt FROM c WHERE c.productId = @productId', + parameters: [{ name: '@productId', value: productId }], + }) + .fetchAll(); + + const byStatus: Record = {}; + const bySource: Record = {}; + const todayStr = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + let todaySignups = 0; + + for (const entry of resources) { + byStatus[entry.status] = (byStatus[entry.status] || 0) + 1; + bySource[entry.source] = (bySource[entry.source] || 0) + 1; + if (entry.createdAt.startsWith(todayStr)) todaySignups++; + } + + return { total: resources.length, byStatus, bySource, todaySignups }; +} diff --git a/services/platform-service/src/modules/waitlist/routes.ts b/services/platform-service/src/modules/waitlist/routes.ts new file mode 100644 index 00000000..204b1d75 --- /dev/null +++ b/services/platform-service/src/modules/waitlist/routes.ts @@ -0,0 +1,471 @@ +/** + * Waitlist REST endpoints — pre-launch signup collection. + * + * Public (no auth, rate-limited): + * POST /public/waitlist/:productId — join waitlist + * POST /public/waitlist/:productId/status — check position (email + token) + * GET /public/waitlist/:productId/count — total signups (social proof) + * GET /public/waitlist/:productId/config — prelaunch config (custom fields) + * POST /public/waitlist/unsubscribe — unsubscribe via token + * + * Admin (JWT auth, role=admin): + * GET /waitlist — list entries + * GET /waitlist/stats — signup analytics + * GET /waitlist/:id — get single entry + * PUT /waitlist/:id — update entry + * DELETE /waitlist/:id — delete entry + * POST /waitlist/invite — batch invite + * POST /waitlist/export — CSV export + */ + +import crypto from 'node:crypto'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import rateLimit from '@fastify/rate-limit'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, ForbiddenError, NotFoundError } from '../../lib/errors.js'; +import { getProduct } from '../products/cache.js'; +import { dispatchWaitlistJoined } from '../../lib/webhooks.js'; +import type { CustomField } from '../products/types.js'; +import * as repo from './repository.js'; +import { + JoinWaitlistSchema, + CheckStatusSchema, + UnsubscribeSchema, + UpdateWaitlistEntrySchema, + WaitlistQuerySchema, + BatchInviteSchema, + type WaitlistEntryDoc, +} from './types.js'; + +// ── Helpers ── + +function requireAdmin(req: FastifyRequest): void { + if (!req.jwtPayload || req.jwtPayload.role !== 'admin') { + throw new ForbiddenError('Admin access required'); + } +} + +function hashIp(ip: string): string { + return crypto.createHash('sha256').update(ip).digest('hex').slice(0, 16); +} + +/** + * Validate customData keys against the product's customFields schema. + * Rejects unknown keys and enforces required fields. + */ +function validateCustomData( + customData: Record, + customFields: CustomField[] +): string | null { + const allowedKeys = new Set(customFields.map(f => f.key)); + + // Reject unknown keys + for (const key of Object.keys(customData)) { + if (!allowedKeys.has(key)) { + return `Unknown custom field: "${key}"`; + } + } + + // Check required fields + for (const field of customFields) { + if (field.required && (customData[field.key] === undefined || customData[field.key] === '')) { + return `Custom field "${field.key}" is required`; + } + } + + return null; // valid +} + +// ── Route registration ── + +export async function waitlistRoutes(app: FastifyInstance) { + // ════════════════════════════════════════════════════════════ + // PUBLIC ROUTES — rate-limited, no auth + // ════════════════════════════════════════════════════════════ + + // Rate limiting for public waitlist routes + await app.register(rateLimit, { + max: 60, + timeWindow: '1 minute', + keyGenerator: req => req.ip, + }); + + // ── Join waitlist ── + app.post( + '/public/waitlist/:productId', + { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, + async (req, reply) => { + const { productId } = req.params as { productId: string }; + + // Validate product exists and is in pre_launch (or active/beta for flexibility) + const product = getProduct(productId); + if (!product) throw new NotFoundError('Product not found'); + if (product.status === 'draft' || product.status === 'disabled') { + throw new BadRequestError(`Product ${productId} is not accepting signups`); + } + + // Check prelaunchConfig + const plConfig = product.prelaunchConfig; + if (!plConfig?.signupEnabled) { + throw new BadRequestError('Waitlist signup is not enabled for this product'); + } + + // Parse input + const parsed = JoinWaitlistSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const input = parsed.data; + + // TODO-1: CAPTCHA validation — when captchaEnabled, verify input.captchaToken + // against the configured provider (Turnstile/hCaptcha/reCAPTCHA). + // Skipped for now — requires provider API keys and HTTP calls. + + // Validate customData against product's custom fields + if (plConfig.customFields.length > 0) { + const err = validateCustomData(input.customData, plConfig.customFields); + if (err) throw new BadRequestError(err); + } + + // Dedupe check + const emailNormalized = repo.normalizeEmail(input.email); + const existing = await repo.getByEmail(emailNormalized, productId); + if (existing) { + // Idempotent: return existing entry position + return { + id: existing.id, + position: existing.position, + alreadyRegistered: true, + referralLink: `?ref=${existing.id}`, + }; + } + + // Check max signups cap + if (plConfig.maxSignups) { + const currentCount = await repo.count(productId); + if (currentCount >= plConfig.maxSignups) { + throw new BadRequestError('Waitlist is full'); + } + } + + // Assign position + const position = await repo.getNextPosition(productId); + + // Handle referral + let referredBy: string | undefined; + let source = input.source; + if (input.ref) { + const referrer = await repo.getById(input.ref); + if (referrer && referrer.productId === productId && referrer.status === 'pending') { + referredBy = referrer.id; + source = 'referral'; + // Bump referrer priority + await repo.update(referrer.id, referrer.email, { + priority: referrer.priority + 1, + }); + } + } + + const now = new Date().toISOString(); + const doc: WaitlistEntryDoc = { + id: `wl_${crypto.randomUUID()}`, + productId, + email: input.email.trim(), + emailNormalized, + name: input.name, + source, + referredBy, + status: 'pending', + position, + priority: 0, + customData: input.customData, + ipHash: hashIp(req.ip), + utmSource: input.utmSource, + utmMedium: input.utmMedium, + utmCampaign: input.utmCampaign, + unsubscribeToken: crypto.randomUUID(), + createdAt: now, + updatedAt: now, + }; + + const created = await repo.create(doc); + + // Fire webhook (async, non-blocking) + dispatchWaitlistJoined({ + entryId: created.id, + email: created.email, + position: created.position, + source: created.source, + }); + + reply.code(201); + return { + id: created.id, + position: created.position, + unsubscribeToken: created.unsubscribeToken, + referralLink: `?ref=${created.id}`, + message: + plConfig.confirmationMessage?.replace('{{position}}', String(created.position)) ?? + `You're #${created.position} on the waitlist!`, + }; + } + ); + + // ── Check status ── + app.post( + '/public/waitlist/:productId/status', + { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, + async req => { + const { productId } = req.params as { productId: string }; + const parsed = CheckStatusSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const emailNormalized = repo.normalizeEmail(parsed.data.email); + const entry = await repo.getByEmail(emailNormalized, productId); + + if (!entry || entry.unsubscribeToken !== parsed.data.unsubscribeToken) { + throw new NotFoundError('Entry not found or token mismatch'); + } + + return { + position: entry.position, + status: entry.status, + createdAt: entry.createdAt, + }; + } + ); + + // ── Count (social proof) ── + app.get('/public/waitlist/:productId/count', async req => { + const { productId } = req.params as { productId: string }; + const product = getProduct(productId); + if (!product) throw new NotFoundError('Product not found'); + + const total = await repo.count(productId); + return { count: total }; + }); + + // ── Public config (custom fields for frontend rendering) ── + app.get('/public/waitlist/:productId/config', async req => { + const { productId } = req.params as { productId: string }; + const product = getProduct(productId); + if (!product) throw new NotFoundError('Product not found'); + + const plConfig = product.prelaunchConfig; + if (!plConfig) { + return { + signupEnabled: false, + customFields: [], + }; + } + + // Return only public-safe fields (strip maxSignups, captcha provider details) + return { + signupEnabled: plConfig.signupEnabled, + launchDate: plConfig.launchDate, + tagline: plConfig.tagline, + logoUrl: plConfig.logoUrl, + customFields: plConfig.customFields, + confirmationMessage: plConfig.confirmationMessage, + redirectUrl: plConfig.redirectUrl, + captchaEnabled: plConfig.captchaEnabled, + }; + }); + + // ── Unsubscribe ── + app.post( + '/public/waitlist/unsubscribe', + { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, + async req => { + const parsed = UnsubscribeSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const entry = await repo.unsubscribe(parsed.data.unsubscribeToken); + if (!entry) { + throw new NotFoundError('Entry not found or already unsubscribed'); + } + + return { status: 'unsubscribed' }; + } + ); + + // ════════════════════════════════════════════════════════════ + // ADMIN ROUTES — JWT auth, role=admin + // ════════════════════════════════════════════════════════════ + + // ── List entries ── + app.get('/waitlist', async req => { + requireAdmin(req); + const raw = req.query as Record; + // Default productId from request context + if (!raw.productId) { + try { + raw.productId = getRequestProductId(req); + } catch { + // If no productId available, list across all products + } + } + const parsed = WaitlistQuerySchema.safeParse(raw); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + return repo.list(parsed.data); + }); + + // ── Stats ── + app.get('/waitlist/stats', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + return repo.stats(productId); + }); + + // ── Get single entry ── + app.get('/waitlist/:id', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + const entry = await repo.getById(id); + if (!entry) throw new NotFoundError('Waitlist entry not found'); + return entry; + }); + + // ── Update entry ── + app.put('/waitlist/:id', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + const entry = await repo.getById(id); + if (!entry) throw new NotFoundError('Waitlist entry not found'); + + const parsed = UpdateWaitlistEntrySchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const updated = await repo.update(id, entry.email, parsed.data); + if (!updated) throw new NotFoundError('Update failed'); + return updated; + }); + + // ── Delete entry ── + app.delete('/waitlist/:id', async (req, reply) => { + requireAdmin(req); + const { id } = req.params as { id: string }; + const entry = await repo.getById(id); + if (!entry) throw new NotFoundError('Waitlist entry not found'); + + const ok = await repo.remove(id, entry.email); + if (!ok) throw new NotFoundError('Delete failed'); + + // TODO-2: Create audit log entry for admin delete action + // await auditRepo.create({ userId: req.jwtPayload!.sub, action: 'waitlist.delete', ... }) + + reply.code(204); + return; + }); + + // ── Batch invite ── + app.post('/waitlist/invite', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + + const parsed = BatchInviteSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { count: inviteCount, strategy } = parsed.data; + + // Fetch pending entries by strategy + const sortBy = strategy === 'priority' ? 'priority' : 'position'; + let entries = await repo.getByStatus(productId, 'pending', sortBy, inviteCount); + + if (strategy === 'random') { + // Shuffle using Fisher-Yates + for (let i = entries.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [entries[i], entries[j]] = [entries[j], entries[i]]; + } + entries = entries.slice(0, inviteCount); + } + + // TODO-3: Auto-generate invitation codes via invitations/ module for each entry. + // For now, just mark entries as invited without linking to invitation codes. + // Wire into invitations/repository.ts create() when ready. + + const now = new Date().toISOString(); + let invited = 0; + let failed = 0; + + for (const entry of entries) { + try { + await repo.update(entry.id, entry.email, { + status: 'invited', + invitedAt: now, + }); + invited++; + } catch { + failed++; + } + } + + // TODO-2: Create audit log entry for batch invite + // await auditRepo.create({ userId: req.jwtPayload!.sub, action: 'waitlist.invite', ... }) + + return { + invited, + failed, + total: entries.length, + }; + }); + + // ── Export (CSV) ── + app.post('/waitlist/export', async (req, reply) => { + requireAdmin(req); + const productId = getRequestProductId(req); + + const { items } = await repo.list({ + productId, + sortBy: 'position', + sortOrder: 'asc', + limit: 10000, + offset: 0, + }); + + // Build CSV (exclude sensitive fields: ipHash, unsubscribeToken) + const headers = [ + 'id', + 'email', + 'name', + 'position', + 'priority', + 'status', + 'source', + 'referredBy', + 'utmSource', + 'utmMedium', + 'utmCampaign', + 'createdAt', + ]; + const csvLines = [headers.join(',')]; + for (const item of items) { + const row = headers.map(h => { + const val = (item as unknown as Record)[h]; + if (val === undefined || val === null) return ''; + const str = String(val); + return str.includes(',') || str.includes('"') ? `"${str.replace(/"/g, '""')}"` : str; + }); + csvLines.push(row.join(',')); + } + + // TODO-2: Create audit log entry for export action + + reply.header('Content-Type', 'text/csv'); + reply.header( + 'Content-Disposition', + `attachment; filename="waitlist-${productId}-${Date.now()}.csv"` + ); + return csvLines.join('\n'); + }); +} diff --git a/services/platform-service/src/modules/waitlist/types.ts b/services/platform-service/src/modules/waitlist/types.ts new file mode 100644 index 00000000..a1c47146 --- /dev/null +++ b/services/platform-service/src/modules/waitlist/types.ts @@ -0,0 +1,97 @@ +/** + * Waitlist types — pre-launch signup collection. + * + * Cosmos container: `waitlist` (partition key: `/email`) + * Product-agnostic: every document includes `productId`. + */ + +import { z } from 'zod'; + +// ── Waitlist entry document ── + +export interface WaitlistEntryDoc { + id: string; + productId: string; + email: string; + emailNormalized: string; + name?: string; + source: 'organic' | 'referral' | 'social' | 'ad' | 'api'; + referredBy?: string; + status: 'pending' | 'invited' | 'converted' | 'unsubscribed'; + position: number; + priority: number; + customData: Record; + invitationCodeId?: string; + invitedAt?: string; + convertedAt?: string; + ipHash?: string; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + unsubscribeToken: string; + createdAt: string; + updatedAt: string; +} + +export const WAITLIST_STATUSES = ['pending', 'invited', 'converted', 'unsubscribed'] as const; +export type WaitlistStatus = (typeof WAITLIST_STATUSES)[number]; + +export const WAITLIST_SOURCES = ['organic', 'referral', 'social', 'ad', 'api'] as const; +export type WaitlistSource = (typeof WAITLIST_SOURCES)[number]; + +// ── Public schemas ── + +export const JoinWaitlistSchema = z.object({ + email: z.string().email().max(320), + name: z.string().max(128).optional(), + source: z.enum(WAITLIST_SOURCES).default('organic'), + ref: z.string().max(128).optional(), + customData: z.record(z.unknown()).default({}), + captchaToken: z.string().max(4096).optional(), + utmSource: z.string().max(256).optional(), + utmMedium: z.string().max(256).optional(), + utmCampaign: z.string().max(256).optional(), +}); + +export const CheckStatusSchema = z.object({ + email: z.string().email(), + unsubscribeToken: z.string().min(1), +}); + +export const UnsubscribeSchema = z.object({ + email: z.string().email(), + unsubscribeToken: z.string().min(1), +}); + +// ── Admin schemas ── + +export const UpdateWaitlistEntrySchema = z.object({ + status: z.enum(WAITLIST_STATUSES).optional(), + priority: z.number().int().min(0).optional(), + name: z.string().max(128).optional(), +}); + +export const WaitlistQuerySchema = z.object({ + productId: z.string().optional(), + status: z.enum(WAITLIST_STATUSES).optional(), + source: z.enum(WAITLIST_SOURCES).optional(), + q: z.string().max(256).optional(), + sortBy: z.enum(['position', 'priority', 'createdAt']).default('position'), + sortOrder: z.enum(['asc', 'desc']).default('asc'), + limit: z.coerce.number().int().min(1).max(500).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +export const BatchInviteSchema = z.object({ + count: z.number().int().min(1).max(500), + strategy: z.enum(['fifo', 'priority', 'random']).default('fifo'), +}); + +// ── Inferred types ── + +export type JoinWaitlistInput = z.infer; +export type CheckStatusInput = z.infer; +export type UnsubscribeInput = z.infer; +export type UpdateWaitlistEntryInput = z.infer; +export type WaitlistQueryInput = z.infer; +export type BatchInviteInput = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index c2f73e6d..ed0007fc 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -45,6 +45,7 @@ import { memoryRoutes } from './modules/memory/routes.js'; import { publicRoutes } from './modules/public/routes.js'; import { tokenRoutes } from './modules/tokens/routes.js'; import { themeRoutes } from './modules/themes/routes.js'; +import { waitlistRoutes } from './modules/waitlist/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { config } from './lib/config.js'; @@ -113,6 +114,8 @@ await app.register(memoryRoutes, { prefix: '/api' }); await app.register(tokenRoutes, { prefix: '/api' }); // Themes module await app.register(themeRoutes, { prefix: '/api' }); +// Waitlist module (pre-launch signups — public + admin routes) +await app.register(waitlistRoutes, { prefix: '/api' }); // Public routes — no auth, registered at top level await app.register(publicRoutes, { prefix: '/api' });