import { all, batch, first, run, stmt } from "./db"; import { randomToken, sha256Hex } from "./crypto"; import { emailTemplates, queueEmail } from "./email"; import { badRequest } from "./errors"; import { publicUrl } from "./events/emit"; import { WAITLIST_INVITE_HOURS } from "~/lib/waitlist"; const CAP_KEY = "signup_cap"; const INVITE_TTL_MS = WAITLIST_INVITE_HOURS * 60 * 60 * 1000; /** Invites sent per advance; a cron or the next cap change picks up the rest. */ const INVITES_PER_RUN = 100; export const MAX_SIGNUP_CAP = 1_000_000; /** Seats held by people invited off the waitlist whose invite hasn't lapsed. */ const HELD_SEATS = "(SELECT COUNT(*) FROM signup_waitlist WHERE invite_expires_at > ? AND bypass_cap = 0)"; const USERS = "(SELECT COUNT(*) FROM users)"; /** The admin-set cap on user accounts, or null for no cap. */ export async function signupCap(): Promise { const row = await first<{ value: string }>("SELECT value FROM instance_settings WHERE key = ?", CAP_KEY); const cap = row ? Number(row.value) : Number.NaN; return Number.isInteger(cap) && cap >= 0 ? cap : null; } export async function setSignupCap(cap: number | null): Promise { if (cap === null) { await run("DELETE FROM instance_settings WHERE key = ?", CAP_KEY); } else { await run( `INSERT INTO instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, CAP_KEY, String(cap), Date.now(), ); } await advanceWaitlist(); } export type SignupSeat = | { kind: "open" } | { kind: "approved" } | { kind: "capped"; cap: number; inviteId: number | null }; /** * Decides whether this signup may take a seat. Without a cap anyone may. With one, an unexpired invite for * this email holds a seat; anyone else needs a seat that is neither taken by a user nor held by an invite. * The returned seat's guard is re-checked inside the insert so racing signups can't overshoot the cap. */ export async function claimSignupSeat(email: string, inviteToken: string | null | undefined): Promise { const cap = await signupCap(); if (cap === null) return { kind: "open" }; const now = Date.now(); if (inviteToken) { const invite = await first<{ id: number; email: string; bypass_cap: number }>( "SELECT id, email, bypass_cap FROM signup_waitlist WHERE invite_token_hash = ? AND invite_expires_at > ?", await sha256Hex(inviteToken), now, ); if (!invite) throw badRequest("This invite has expired. Join the waitlist again to get a new spot."); if (invite.email.toLowerCase() !== email.toLowerCase()) { throw badRequest("Sign up with the email address the invite was sent to"); } if (invite.bypass_cap) return { kind: "approved" }; return { kind: "capped", cap, inviteId: invite.id }; } const held = await first<{ taken: number }>(`SELECT ${USERS} + ${HELD_SEATS} AS taken`, now); if ((held?.taken ?? 0) >= cap) throw badRequest(SIGNUPS_FULL); return { kind: "capped", cap, inviteId: null }; } export const SIGNUPS_FULL = "Sign ups are full right now. Join the waitlist and we'll email you when a spot opens."; /** SQL condition (and params) that keeps the account inserts within the cap at commit time. */ export function seatGuard(seat: SignupSeat): { sql: string; params: number[] } | null { if (seat.kind !== "capped") return null; // An invited signup's own held seat is part of HELD_SEATS, so it only needs room among users. if (seat.inviteId !== null) return { sql: `${USERS} < ?`, params: [seat.cap] }; return { sql: `${USERS} + ${HELD_SEATS} < ?`, params: [Date.now(), seat.cap] }; } /** Once the account exists, its waitlist row (invited or not) is done. */ export async function releaseWaitlistSpot(email: string): Promise { await run("DELETE FROM signup_waitlist WHERE email = ?", email); await advanceWaitlist(); } /** Whether a plain (uninvited) signup would be turned away right now. */ export async function signupsFull(): Promise { const cap = await signupCap(); if (cap === null) return false; const held = await first<{ taken: number }>(`SELECT ${USERS} + ${HELD_SEATS} AS taken`, Date.now()); return (held?.taken ?? 0) >= cap; } /** Adds an email to the back of the line and returns its place among those still waiting (1-based). */ export async function joinWaitlist(email: string): Promise { if (!(await signupsFull())) throw badRequest("Sign ups are open. Create your account now."); if (await first("SELECT 1 FROM users WHERE email = ?", email)) { throw badRequest("An account with that email already exists"); } await run( "INSERT INTO signup_waitlist (email, created_at) VALUES (?, ?) ON CONFLICT (email) DO NOTHING", email, Date.now(), ); const row = await first<{ place: number }>( `SELECT COUNT(*) AS place FROM signup_waitlist WHERE invited_at IS NULL AND id <= (SELECT id FROM signup_waitlist WHERE email = ?)`, email, ); return Math.max(1, row?.place ?? 1); } /** The email an invite token was sent to, so the signup form can prefill it. */ export async function invitedEmail(inviteToken: string): Promise { const row = await first<{ email: string }>( "SELECT email FROM signup_waitlist WHERE invite_token_hash = ? AND invite_expires_at > ?", await sha256Hex(inviteToken), Date.now(), ); return row?.email ?? null; } /** * Line order is insertion order (the AUTOINCREMENT id), which stays strict even when two people join in the same millisecond. * Drops lapsed invites (that person loses their place), then invites the next people in line, oldest * first, for every seat that is neither taken nor held. Runs on cap changes, signups and the cron. */ export async function advanceWaitlist(): Promise { const now = Date.now(); await run("DELETE FROM signup_waitlist WHERE invite_expires_at IS NOT NULL AND invite_expires_at <= ?", now); // Who is waiting comes first: counting every user for the cap is skipped while nobody is. const waiting = await all<{ id: number; email: string }>( "SELECT id, email FROM signup_waitlist WHERE invited_at IS NULL ORDER BY id LIMIT ?", INVITES_PER_RUN, ); if (waiting.length === 0) return 0; const cap = await signupCap(); let free = INVITES_PER_RUN; if (cap !== null) { const held = await first<{ taken: number }>(`SELECT ${USERS} + ${HELD_SEATS} AS taken`, now); free = Math.min(INVITES_PER_RUN, cap - (held?.taken ?? 0)); } if (free <= 0) return 0; const next = waiting.slice(0, free); const invites = await Promise.all(next.map(async (row) => ({ ...row, token: randomToken("invite") }))); // The `invited_at IS NULL` guard keeps a concurrent advance from inviting the same person twice. const results = await batch( await Promise.all( invites.map(async (invite) => stmt( `UPDATE signup_waitlist SET invite_token_hash = ?, invited_at = ?, invite_expires_at = ? WHERE id = ? AND invited_at IS NULL`, await sha256Hex(invite.token), now, now + INVITE_TTL_MS, invite.id, ), ), ), ); let sent = 0; for (const [index, invite] of invites.entries()) { if ((results[index]?.meta.changes ?? 0) === 0) continue; const url = publicUrl(`/signup?invite=${encodeURIComponent(invite.token)}`); await queueEmail(invite.email, emailTemplates.waitlistInvite(url, `${WAITLIST_INVITE_HOURS} hours`)); sent++; } return sent; } export type WaitlistSummary = { cap: number | null; users: number; waiting: number; invited: number; }; export type WaitlistEntry = { id: number; email: string; createdAt: number; invitedAt: number | null; expiresAt: number | null; approved: boolean; }; export async function waitlistSummary(): Promise { const now = Date.now(); const [cap, counts] = await Promise.all([ signupCap(), first<{ users: number; waiting: number; invited: number }>( `SELECT ${USERS} AS users, (SELECT COUNT(*) FROM signup_waitlist WHERE invited_at IS NULL) AS waiting, ${HELD_SEATS} AS invited`, now, ), ]); return { cap, users: counts?.users ?? 0, waiting: counts?.waiting ?? 0, invited: counts?.invited ?? 0, }; } const APPROVAL_DAYS = 7; /** * An admin lets one waitlisted person in regardless of the cap. Their invite holds no seat (so the line keeps * its place) and lasts a week; once they join they count as a user like anyone else. */ export async function approveWaitlistEntry(id: number): Promise { const row = await first<{ email: string }>("SELECT email FROM signup_waitlist WHERE id = ?", id); if (!row) throw badRequest("That person is no longer on the waitlist"); const token = randomToken("invite"); const now = Date.now(); await run( `UPDATE signup_waitlist SET invite_token_hash = ?, invited_at = ?, invite_expires_at = ?, bypass_cap = 1 WHERE id = ?`, await sha256Hex(token), now, now + APPROVAL_DAYS * 24 * 60 * 60 * 1000, id, ); const url = publicUrl(`/signup?invite=${encodeURIComponent(token)}`); await queueEmail(row.email, emailTemplates.waitlistInvite(url, `${APPROVAL_DAYS} days`)); // If this person already held a capped seat, that seat is free again for the next in line. await advanceWaitlist(); return row.email; } export const WAITLIST_PAGE_SIZE = 25; const MAX_LIKE_BYTES = 50; function clampLikeTerm(term: string): string { let out = ""; for (const char of term) { const next = out + char; const escaped = next.replace(/[\\%_]/g, (match) => `\\${match}`); if (new TextEncoder().encode(escaped).byteLength + 2 > MAX_LIKE_BYTES) break; out = next; } return out; } export type WaitlistStatus = "all" | "waiting" | "invited" | "approved"; const STATUS_FILTERS: Record = { all: "1 = 1", waiting: "invited_at IS NULL", invited: "invited_at IS NOT NULL AND bypass_cap = 0", approved: "bypass_cap = 1", }; /** One page of the line, in line order, filtered by email substring and status. */ export async function listWaitlist(filter: { query: string; status: WaitlistStatus; page: number; }): Promise<{ rows: WaitlistEntry[]; total: number; page: number; pageSize: number }> { // D1 rejects LIKE patterns over 50 bytes; the escaped term plus both wildcards has to fit. const like = `%${clampLikeTerm(filter.query.trim()).replace(/[\\%_]/g, (char) => `\\${char}`)}%`; const where = `email LIKE ? ESCAPE '\\' AND ${STATUS_FILTERS[filter.status]}`; const offset = (filter.page - 1) * WAITLIST_PAGE_SIZE; const [count, rows] = await Promise.all([ first<{ total: number }>(`SELECT COUNT(*) AS total FROM signup_waitlist WHERE ${where}`, like), all<{ id: number; email: string; created_at: number; invited_at: number | null; invite_expires_at: number | null; bypass_cap: number; }>( `SELECT id, email, created_at, invited_at, invite_expires_at, bypass_cap FROM signup_waitlist WHERE ${where} ORDER BY invited_at IS NULL, id LIMIT ? OFFSET ?`, like, WAITLIST_PAGE_SIZE, offset, ), ]); return { rows: rows.map((row) => ({ id: row.id, email: row.email, approved: row.bypass_cap === 1, createdAt: row.created_at, invitedAt: row.invited_at, expiresAt: row.invite_expires_at, })), total: count?.total ?? 0, page: filter.page, pageSize: WAITLIST_PAGE_SIZE, }; }