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:
parent
66e657a646
commit
2692c918ce
@ -10,6 +10,25 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
|||||||
notification_prefs: { partitionKeyPath: '/userId' },
|
notification_prefs: { partitionKeyPath: '/userId' },
|
||||||
audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 },
|
audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 },
|
||||||
feature_flags: { partitionKeyPath: '/id' },
|
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).
|
// Mobile capture primitives (MindLyst-style).
|
||||||
memory_items: { partitionKeyPath: '/userId' },
|
memory_items: { partitionKeyPath: '/userId' },
|
||||||
daily_briefs: { partitionKeyPath: '/userId' },
|
daily_briefs: { partitionKeyPath: '/userId' },
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
* Products register webhook URLs via env vars:
|
* Products register webhook URLs via env vars:
|
||||||
* WEBHOOK_INVITATION_REDEEMED_URL — called after an invitation code is redeemed
|
* WEBHOOK_INVITATION_REDEEMED_URL — called after an invitation code is redeemed
|
||||||
* WEBHOOK_REFERRAL_STATUS_URL — called when a referral status transitions
|
* 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.
|
* 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> {
|
export function dispatchReferralStatusChanged(data: Record<string, unknown>): Promise<boolean> {
|
||||||
return dispatchWebhook(process.env.WEBHOOK_REFERRAL_STATUS_URL, 'referral.status_changed', data);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
258
services/platform-service/src/modules/waitlist/repository.ts
Normal file
258
services/platform-service/src/modules/waitlist/repository.ts
Normal 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 };
|
||||||
|
}
|
||||||
471
services/platform-service/src/modules/waitlist/routes.ts
Normal file
471
services/platform-service/src/modules/waitlist/routes.ts
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
97
services/platform-service/src/modules/waitlist/types.ts
Normal file
97
services/platform-service/src/modules/waitlist/types.ts
Normal 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>;
|
||||||
@ -45,6 +45,7 @@ import { memoryRoutes } from './modules/memory/routes.js';
|
|||||||
import { publicRoutes } from './modules/public/routes.js';
|
import { publicRoutes } from './modules/public/routes.js';
|
||||||
import { tokenRoutes } from './modules/tokens/routes.js';
|
import { tokenRoutes } from './modules/tokens/routes.js';
|
||||||
import { themeRoutes } from './modules/themes/routes.js';
|
import { themeRoutes } from './modules/themes/routes.js';
|
||||||
|
import { waitlistRoutes } from './modules/waitlist/routes.js';
|
||||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { config } from './lib/config.js';
|
import { config } from './lib/config.js';
|
||||||
|
|
||||||
@ -113,6 +114,8 @@ await app.register(memoryRoutes, { prefix: '/api' });
|
|||||||
await app.register(tokenRoutes, { prefix: '/api' });
|
await app.register(tokenRoutes, { prefix: '/api' });
|
||||||
// Themes module
|
// Themes module
|
||||||
await app.register(themeRoutes, { prefix: '/api' });
|
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
|
// Public routes — no auth, registered at top level
|
||||||
await app.register(publicRoutes, { prefix: '/api' });
|
await app.register(publicRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user