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' },
|
||||
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' },
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
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 { 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' });
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user