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
This commit is contained in:
saravanakumardb1 2026-02-16 22:45:14 -08:00
parent 66e657a646
commit 2692c918ce
6 changed files with 854 additions and 0 deletions

View File

@ -10,6 +10,25 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
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' },

View File

@ -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<string, unknown>): Promi
export function dispatchReferralStatusChanged(data: Record<string, unknown>): Promise<boolean> {
return dispatchWebhook(process.env.WEBHOOK_REFERRAL_STATUS_URL, 'referral.status_changed', data);
}
/** Dispatch a waitlist.joined webhook. */
export function dispatchWaitlistJoined(data: Record<string, unknown>): Promise<boolean> {
return dispatchWebhook(process.env.WEBHOOK_WAITLIST_JOINED_URL, 'waitlist.joined', data);
}

View File

@ -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<WaitlistEntryDoc> {
const { resource } = await container().items.create(doc);
return resource as WaitlistEntryDoc;
}
// ── Read (single) ──
export async function getById(id: string): Promise<WaitlistEntryDoc | null> {
// id-based lookup requires cross-partition query (partition is /email)
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
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<WaitlistEntryDoc | null> {
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
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<WaitlistEntryDoc | null> {
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
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<number>({
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<WaitlistEntryDoc>({
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<number> {
const { resources } = await container()
.items.query<number>({
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<number> {
const { resources } = await container()
.items.query<number>({
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<WaitlistEntryDoc>
): Promise<WaitlistEntryDoc | null> {
try {
const { resource: existing } = await container().item(id, email).read<WaitlistEntryDoc>();
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<WaitlistEntryDoc | null> {
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<boolean> {
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<WaitlistEntryDoc[]> {
const sortCol = sortBy === 'priority' ? 'c.priority DESC' : 'c.position ASC';
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
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<string, number>;
bySource: Record<string, number>;
todaySignups: number;
}
export async function stats(productId: string): Promise<WaitlistStats> {
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
query: 'SELECT c.status, c.source, c.createdAt FROM c WHERE c.productId = @productId',
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
const byStatus: Record<string, number> = {};
const bySource: Record<string, number> = {};
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 };
}

View File

@ -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<string, unknown>,
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<string, string>;
// 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<string, unknown>)[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');
});
}

View File

@ -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<string, unknown>;
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<typeof JoinWaitlistSchema>;
export type CheckStatusInput = z.infer<typeof CheckStatusSchema>;
export type UnsubscribeInput = z.infer<typeof UnsubscribeSchema>;
export type UpdateWaitlistEntryInput = z.infer<typeof UpdateWaitlistEntrySchema>;
export type WaitlistQueryInput = z.infer<typeof WaitlistQuerySchema>;
export type BatchInviteInput = z.infer<typeof BatchInviteSchema>;

View File

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